fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so
- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so the dialog's notice is the only one whichever comes first; another device's upload follows after. - One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token dialog's two Copy buttons are named by what they copy. - The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no New link beside a list it has not seen. - The settings texts say that signing out elsewhere or a password change revokes the API tokens. - DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the Links, Tokens and Files rounds.
This commit is contained in:
+2
-1
@@ -662,7 +662,7 @@ of a full-width panel and nothing on the right.
|
|||||||
## Decision log
|
## Decision log
|
||||||
|
|
||||||
| Date | Decision | Chosen | Over | Why |
|
| Date | Decision | Chosen | Over | Why |
|
||||||
| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||||
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||||
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||||
@@ -782,3 +782,4 @@ of a full-width panel and nothing on the right.
|
|||||||
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
|
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
|
||||||
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
||||||
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
||||||
|
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { displayName } from "../lib/api/account";
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { formatDateTime } from "../lib/format";
|
import { formatDateTime } from "../lib/format";
|
||||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
import { SectionLabel } from "./ui/Headings";
|
||||||
|
import { useCopyFeedback } from "./useCopyFeedback";
|
||||||
|
|
||||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||||
// one account lives.
|
// one account lives.
|
||||||
@@ -59,7 +59,7 @@ export default function AccountPanel({
|
|||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const { copied, copy: copyValue } = useCopyFeedback();
|
||||||
|
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: qk.adminUserAccess(user.id),
|
queryKey: qk.adminUserAccess(user.id),
|
||||||
@@ -88,19 +88,11 @@ export default function AccountPanel({
|
|||||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = async () => {
|
|
||||||
if (!link) return;
|
|
||||||
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
|
||||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||||
// minimal self-host this no-email link was built for.
|
// minimal self-host this no-email link was built for.
|
||||||
if (await copyText(link.url)) {
|
const copy = () => {
|
||||||
setCopied(true);
|
if (link) void copyValue(link.url);
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
|
|
||||||
// action failing. Say so rather than leaving the button looking inert.
|
|
||||||
notify({ level: "warning", message: t("users.account.copyFailed") });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const a: AdminUserAccess | undefined = q.data;
|
const a: AdminUserAccess | undefined = q.data;
|
||||||
|
|||||||
@@ -187,6 +187,11 @@ describe("API tokens · making one", () => {
|
|||||||
);
|
);
|
||||||
// The keyboard lands on copying it, not on the closed form's button.
|
// The keyboard lands on copying it, not on the closed form's button.
|
||||||
expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
|
expect(document.activeElement?.textContent).toBe("settings.tokens.copy");
|
||||||
|
// Two Copy buttons in one dialog: each is named by the value it copies.
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
|
||||||
|
).toBe(document.activeElement);
|
||||||
|
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
|
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ import {
|
|||||||
type TokenRight,
|
type TokenRight,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { compareText } from "../lib/collate";
|
import { compareText } from "../lib/collate";
|
||||||
import { formatDate } from "../lib/format";
|
import { formatDate } from "../lib/format";
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
|
import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||||
@@ -37,6 +35,7 @@ import { RelativeTime } from "./ui/RelativeTime";
|
|||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
|
import { useCopyFeedback } from "./useCopyFeedback";
|
||||||
|
|
||||||
// Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log,
|
// Settings → API tokens: a machine's key to the user's Files library (docs/DESIGN.md decision log,
|
||||||
// "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and
|
// "API tokens"). A row per token in three lines — what it is, what it opens, when it was used and
|
||||||
@@ -477,16 +476,7 @@ function CopyField({
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const { copied, copy } = useCopyFeedback();
|
||||||
const copy = async () => {
|
|
||||||
if (await copyText(value)) {
|
|
||||||
setCopied(true);
|
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The value is on screen and selectable: a failed copy is a convenience that failed.
|
|
||||||
notify({ level: "warning", message: t("common.copy.failed") });
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted">{label}</span>
|
<span className="text-xs text-muted">{label}</span>
|
||||||
@@ -499,7 +489,9 @@ function CopyField({
|
|||||||
size="sm"
|
size="sm"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
onClick={() => void copy()}
|
// Two of these sit in one dialog: each is named by the value it copies.
|
||||||
|
aria-label={t("settings.tokens.copyField", { field: label })}
|
||||||
|
onClick={() => void copy(value)}
|
||||||
>
|
>
|
||||||
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
|
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -177,6 +177,27 @@ describe("ShareDialog · a folder", () => {
|
|||||||
expect((allow() as HTMLInputElement).checked).toBe(true);
|
expect((allow() as HTMLInputElement).checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a list that did not load says so with Retry, and offers no New link beside it", async () => {
|
||||||
|
h.folderLinks
|
||||||
|
.mockReset()
|
||||||
|
.mockRejectedValueOnce(new Error("503"))
|
||||||
|
.mockResolvedValue([link(4)]);
|
||||||
|
h.createFolderLink.mockReset();
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("downloads.share.linksFailed")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("downloads.share.noLinks")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "downloads.share.newLink" })).toBeNull();
|
||||||
|
expect(h.createFolderLink).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
|
||||||
|
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "downloads.share.newLink" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
|
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
|
||||||
h.folderLinks.mockReset().mockResolvedValue([]);
|
h.folderLinks.mockReset().mockResolvedValue([]);
|
||||||
h.createFolderLink.mockReset().mockResolvedValue(link(6));
|
h.createFolderLink.mockReset().mockResolvedValue(link(6));
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import Modal from "./ui/Modal";
|
|||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "./confirmContext";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { formatDate } from "../lib/format";
|
import { formatDate } from "../lib/format";
|
||||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
|
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
|
||||||
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
|
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
|
||||||
@@ -17,6 +16,8 @@ import { Input, InnerInput } from "./ui/Input";
|
|||||||
import { CheckRow } from "./ui/NativeControls";
|
import { CheckRow } from "./ui/NativeControls";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import UserPicker from "./UserPicker";
|
import UserPicker from "./UserPicker";
|
||||||
|
import { useCopyFeedback } from "./useCopyFeedback";
|
||||||
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||||
|
|
||||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||||
@@ -68,18 +69,13 @@ function UserShare({ job }: { job: DownloadJob }) {
|
|||||||
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [copied, setCopied] = useState(false);
|
const { copied, copy: copyValue } = useCopyFeedback();
|
||||||
const [editPass, setEditPass] = useState(false);
|
const [editPass, setEditPass] = useState(false);
|
||||||
const [newPass, setNewPass] = useState("");
|
const [newPass, setNewPass] = useState("");
|
||||||
const fullUrl = link.url && window.location.origin + link.url;
|
const fullUrl = link.url && window.location.origin + link.url;
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = () => {
|
||||||
if (fullUrl && (await copyText(fullUrl))) {
|
if (fullUrl) void copyValue(fullUrl);
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
} else {
|
|
||||||
notify({ level: "error", message: t("downloads.share.copyFailed") });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const toggle = useMutation({
|
const toggle = useMutation({
|
||||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||||
@@ -299,16 +295,28 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* A list that did not load is said so, with Retry — never shown as "no links", and with no
|
||||||
|
"New link" beside it, which would add a second public link next to ones not seen. */}
|
||||||
|
{!links.data ? (
|
||||||
|
links.isError ? (
|
||||||
|
<StateMessage tone="error" compact onRetry={() => void links.refetch()}>
|
||||||
|
{t("downloads.share.linksFailed")}
|
||||||
|
</StateMessage>
|
||||||
|
) : (
|
||||||
|
<LoadingState label={t("common.loading")} className="py-4" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(links.data ?? []).map((l) => (
|
{links.data.map((l) => (
|
||||||
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||||
))}
|
))}
|
||||||
{links.data && links.data.length === 0 && !creating && !create.isPending && (
|
{links.data.length === 0 && !creating && !create.isPending && (
|
||||||
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{creating ? (
|
{!links.data ? null : creating ? (
|
||||||
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
|
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
|
||||||
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
|
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
|
||||||
{t("downloads.share.allowDownload")}
|
{t("downloads.share.allowDownload")}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const h = vi.hoisted(() => ({
|
|||||||
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
showInTray: vi.fn(),
|
showInTray: vi.fn(),
|
||||||
noteAnnounced: vi.fn(),
|
uploadStarted: vi.fn(),
|
||||||
|
uploadEnded: vi.fn(),
|
||||||
openFile: vi.fn(),
|
openFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("../lib/downloadQueue", () => ({
|
vi.mock("../lib/downloadQueue", () => ({
|
||||||
showInTray: h.showInTray,
|
showInTray: h.showInTray,
|
||||||
noteAnnounced: h.noteAnnounced,
|
uploadStarted: h.uploadStarted,
|
||||||
|
uploadEnded: h.uploadEnded,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import UploadDialog from "./UploadDialog";
|
import UploadDialog from "./UploadDialog";
|
||||||
@@ -117,7 +119,7 @@ describe("UploadDialog", () => {
|
|||||||
message: "downloads.upload.addedConverting",
|
message: "downloads.upload.addedConverting",
|
||||||
});
|
});
|
||||||
// Its finish is still to come, and the tray's to announce.
|
// Its finish is still to come, and the tray's to announce.
|
||||||
expect(h.noteAnnounced).not.toHaveBeenCalledWith(43);
|
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
||||||
@@ -132,7 +134,8 @@ describe("UploadDialog", () => {
|
|||||||
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
||||||
expect(h.showInTray).not.toHaveBeenCalled();
|
expect(h.showInTray).not.toHaveBeenCalled();
|
||||||
// Done and said so here: the queue's next look must not announce it again, as a download.
|
// Done and said so here: the queue's next look must not announce it again, as a download.
|
||||||
expect(h.noteAnnounced).toHaveBeenCalledWith(42);
|
expect(h.uploadStarted).toHaveBeenCalled();
|
||||||
|
expect(h.uploadEnded).toHaveBeenCalledWith(42);
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
h.notify.mockClear();
|
h.notify.mockClear();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
|
|||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { noteAnnounced, showInTray } from "../lib/downloadQueue";
|
import { showInTray, uploadEnded, uploadStarted } from "../lib/downloadQueue";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../lib/api";
|
||||||
import { useFilesActions } from "./filesContext";
|
import { useFilesActions } from "./filesContext";
|
||||||
import { formatBytes } from "../lib/format";
|
import { formatBytes } from "../lib/format";
|
||||||
@@ -28,6 +28,8 @@ import { Input } from "./ui/Input";
|
|||||||
// skip the whole dialog.
|
// skip the whole dialog.
|
||||||
const uploadedKey = (status: string) =>
|
const uploadedKey = (status: string) =>
|
||||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
||||||
|
// The job the upload's own notice announces (a done one), for the queue not to say it again.
|
||||||
|
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
|
||||||
export default function UploadDialog({
|
export default function UploadDialog({
|
||||||
folderId = null,
|
folderId = null,
|
||||||
initialFile = null,
|
initialFile = null,
|
||||||
@@ -96,6 +98,7 @@ export default function UploadDialog({
|
|||||||
abortRef.current = ac;
|
abortRef.current = ac;
|
||||||
setPct(0);
|
setPct(0);
|
||||||
const displayName = name.trim() || undefined;
|
const displayName = name.trim() || undefined;
|
||||||
|
uploadStarted();
|
||||||
try {
|
try {
|
||||||
const job = await api.uploadFile(file, {
|
const job = await api.uploadFile(file, {
|
||||||
displayName,
|
displayName,
|
||||||
@@ -105,7 +108,7 @@ export default function UploadDialog({
|
|||||||
});
|
});
|
||||||
// Said here, so the queue does not say it again; one the worker still converts is the tray's
|
// Said here, so the queue does not say it again; one the worker still converts is the tray's
|
||||||
// to announce when it is done.
|
// to announce when it is done.
|
||||||
if (job.status === "done") noteAnnounced(job.id);
|
uploadEnded(announces(job));
|
||||||
invalidateDownloads(qc);
|
invalidateDownloads(qc);
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
@@ -123,6 +126,7 @@ export default function UploadDialog({
|
|||||||
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
|
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
|
||||||
// named and no job in the list. The comment here claimed the global error modal had it; it
|
// named and no job in the list. The comment here claimed the global error modal had it; it
|
||||||
// had not since the shared `reportFailure` landed.
|
// had not since the shared `reportFailure` landed.
|
||||||
|
uploadEnded(null);
|
||||||
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
|
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
|
||||||
// Re-arm the form so the user can retry or pick again.
|
// Re-arm the form so the user can retry or pick again.
|
||||||
setPct(null);
|
setPct(null);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A Copy button beside its value: "Copied" for a moment and a notice a screen reader hears, or a
|
||||||
|
// warning when the clipboard refused — the same at every such button.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ copyText: vi.fn(async (_t: string) => true), notify: vi.fn() }));
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
vi.mock("../lib/clipboard", () => ({ copyText: h.copyText }));
|
||||||
|
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
||||||
|
|
||||||
|
import { useCopyFeedback } from "./useCopyFeedback";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
h.notify.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCopyFeedback", () => {
|
||||||
|
it("a copy that worked reads Copied for a moment and is said out loud", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { result } = renderHook(() => useCopyFeedback());
|
||||||
|
await act(() => result.current.copy("sfl_x"));
|
||||||
|
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
||||||
|
expect(result.current.copied).toBe(true);
|
||||||
|
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
|
||||||
|
act(() => void vi.advanceTimersByTime(2000));
|
||||||
|
expect(result.current.copied).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
|
||||||
|
h.copyText.mockResolvedValueOnce(false);
|
||||||
|
const { result } = renderHook(() => useCopyFeedback());
|
||||||
|
await act(() => result.current.copy("sfl_x"));
|
||||||
|
expect(result.current.copied).toBe(false);
|
||||||
|
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
|
||||||
|
// How long a Copy button reads "Copied" after it worked.
|
||||||
|
const COPIED_MS = 2000;
|
||||||
|
|
||||||
|
/** The feedback of a Copy button that sits beside the value it copies (a token, a share link, a
|
||||||
|
* set-password link), the same at every such button: it reads "Copied" for a moment, and a notice
|
||||||
|
* says it too — the notices are a live region, so a screen reader hears what a swapped label would
|
||||||
|
* not tell it. A copy that failed is a warning, not an error: the value is on screen and
|
||||||
|
* selectable, so it is a convenience that failed, not the action.
|
||||||
|
*
|
||||||
|
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
|
||||||
|
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copy = async (value: string) => {
|
||||||
|
if (await copyText(value)) {
|
||||||
|
setCopied(true);
|
||||||
|
notify({ level: "success", message: t("common.copy.done") });
|
||||||
|
window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify({ level: "warning", message: t("common.copy.failed") });
|
||||||
|
};
|
||||||
|
return { copied, copy };
|
||||||
|
}
|
||||||
@@ -77,11 +77,14 @@ export default function DesignLab() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<nav className="flex flex-wrap gap-1 text-xs">
|
<nav className="flex flex-wrap gap-1 text-xs">
|
||||||
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
|
<a href="#open-links" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
Tokens
|
Links ✓
|
||||||
</a>
|
</a>
|
||||||
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
|
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
Files
|
Tokens ✓
|
||||||
|
</a>
|
||||||
|
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
|
Files ✓
|
||||||
</a>
|
</a>
|
||||||
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
A ✓
|
A ✓
|
||||||
@@ -109,23 +112,25 @@ export default function DesignLab() {
|
|||||||
<header id="open-links" className="mt-6 scroll-mt-20">
|
<header id="open-links" className="mt-6 scroll-mt-20">
|
||||||
<h2 className="text-xl font-bold">Decided · Folder links</h2>
|
<h2 className="text-xl font-bold">Decided · Folder links</h2>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
A /watch link that opens a whole folder. Picked: A, A, A — the variants with the accent
|
A /watch link that opens a whole folder. Picked: Copy link beside Share with link… in
|
||||||
edge (built; on the public list a picture-less row keeps its glyph above the length).
|
the folder menu, a list the link opens with the player above it, a link mark in the
|
||||||
|
rail.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<LinksRound />
|
<LinksRound />
|
||||||
<header id="open-tokens" className="mt-6 scroll-mt-20">
|
<header id="open-tokens" className="mt-6 scroll-mt-20">
|
||||||
<h2 className="text-xl font-bold">Decided · API tokens, a machine's key to Files</h2>
|
<h2 className="text-xl font-bold">Decided · API tokens, a machine's key to Files</h2>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Settings → API tokens. Picked: A, A, A — the variants with the accent edge (built; the
|
Settings → API tokens. Picked: a row per token in three lines, the kind asked first (Hub
|
||||||
expiry is a segmented control, DESIGN.md "A choice between a few values").
|
csere or Egyedi, rights as chips per folder), the token shown once in the same dialog.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<TokensRound />
|
<TokensRound />
|
||||||
<header id="open-files" className="mt-6 scroll-mt-20">
|
<header id="open-files" className="mt-6 scroll-mt-20">
|
||||||
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
|
<h2 className="text-xl font-bold">Decided · Files, the one library</h2>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
The page that replaces Downloads. The recommended variant has the accent edge.
|
The page that replaced Downloads. Picked: list rows (the grid a toggle), an app-wide
|
||||||
|
download tray, a done notification that copies the link.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<FilesRound />
|
<FilesRound />
|
||||||
|
|||||||
@@ -34,15 +34,16 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
import { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import { Decision, Sample, Variant } from "./labKit";
|
import { Reference, Sample } from "./labKit";
|
||||||
import { Badge } from "../components/ui/Badge";
|
import { Badge } from "../components/ui/Badge";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||||
import { fieldClass } from "../components/ui/fieldStyles";
|
import { fieldClass } from "../components/ui/fieldStyles";
|
||||||
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
||||||
|
|
||||||
// The Files round: the one library that replaces the Downloads page. Static markup on purpose (the
|
// Files, the one library, DECIDED (owner, 2026-10-11): the page that replaced Downloads. Static markup
|
||||||
// lab's rule): the sample rows are the owner's own kind of content, nothing here talks to the server.
|
// on purpose (the lab's rule): the sample rows are the owner's own kind of content, nothing here talks
|
||||||
|
// to the server.
|
||||||
|
|
||||||
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
||||||
|
|
||||||
@@ -158,47 +159,7 @@ function SourceBadge({ item }: { item: Item }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StateBadges({ item }: { item: Item }) {
|
function Thumb({ item, className }: { item: Item; className: string }) {
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{item.state === "phone" && (
|
|
||||||
<Badge title="Telefonon is lejátszható">
|
|
||||||
<Smartphone />
|
|
||||||
<span className="text-success">✓</span>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{item.state === "converting" && (
|
|
||||||
<Badge tone="accent">
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
Telefonra alakítás…
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{item.retention === "kept" && (
|
|
||||||
<Badge>
|
|
||||||
<Pin />
|
|
||||||
Megtartva
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{item.retention && item.retention !== "kept" && item.retention.days <= 7 && (
|
|
||||||
<Badge tone="warn">
|
|
||||||
<Clock />
|
|
||||||
Lejár {item.retention.days} nap múlva
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Thumb({
|
|
||||||
item,
|
|
||||||
className,
|
|
||||||
clipOnPicture = false,
|
|
||||||
}: {
|
|
||||||
item: Item;
|
|
||||||
className: string;
|
|
||||||
/** A grid card marks a clip on its picture; a small list thumb has no room beside the length. */
|
|
||||||
clipOnPicture?: boolean;
|
|
||||||
}) {
|
|
||||||
const Glyph = item.kind === "video" ? null : FileText;
|
const Glyph = item.kind === "video" ? null : FileText;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -210,19 +171,24 @@ function Thumb({
|
|||||||
>
|
>
|
||||||
{Glyph && <Glyph className="absolute inset-0 m-auto size-6 text-muted" />}
|
{Glyph && <Glyph className="absolute inset-0 m-auto size-6 text-muted" />}
|
||||||
{item.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{item.length}</span>}
|
{item.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{item.length}</span>}
|
||||||
{clipOnPicture && item.clip && (
|
|
||||||
<span className={clsx(MEDIA_BADGE, "left-1 top-1 inline-flex items-center gap-0.5")}>
|
|
||||||
<Scissors className="size-3" />
|
|
||||||
klip
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RowActions({ item }: { item: Item }) {
|
// Only the ⋯ holds a place: the quick icons come up over the date while the row is hovered or holds
|
||||||
|
// the keyboard (the mail-list pattern), so the name gets their room. An action that does not apply
|
||||||
|
// keeps its slot empty (a file has no "copy the play link").
|
||||||
|
function RowActions({ item, focused }: { item: Item; focused: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className="flex shrink-0 items-center gap-0.5">
|
<span className="relative flex items-center justify-end">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"absolute right-full top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-bg transition-opacity",
|
||||||
|
focused
|
||||||
|
? "opacity-100"
|
||||||
|
: "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{item.kind === "video" ? (
|
{item.kind === "video" ? (
|
||||||
<span className={ICON} title="Link másolása">
|
<span className={ICON} title="Link másolása">
|
||||||
<Link />
|
<Link />
|
||||||
@@ -236,6 +202,7 @@ function RowActions({ item }: { item: Item }) {
|
|||||||
<span className={ICON} title="Letöltés">
|
<span className={ICON} title="Letöltés">
|
||||||
<Download />
|
<Download />
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
<span className={ICON} title="Továbbiak">
|
<span className={ICON} title="Továbbiak">
|
||||||
<MoreHorizontal />
|
<MoreHorizontal />
|
||||||
</span>
|
</span>
|
||||||
@@ -243,7 +210,7 @@ function RowActions({ item }: { item: Item }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The page frame: sidebar + toolbar, shared by both layouts ────────────────────────────────────
|
// ── The page frame: sidebar + toolbar ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SideEntry({
|
function SideEntry({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -303,7 +270,7 @@ function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({ grid }: { grid: boolean }) {
|
function Toolbar() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={clsx(fieldClass({ size: "sm" }), "flex min-w-64 flex-1 items-center gap-2")}>
|
<span className={clsx(fieldClass({ size: "sm" }), "flex min-w-64 flex-1 items-center gap-2")}>
|
||||||
@@ -325,10 +292,10 @@ function Toolbar({ grid }: { grid: boolean }) {
|
|||||||
Feltöltés
|
Feltöltés
|
||||||
</span>
|
</span>
|
||||||
<span className="flex">
|
<span className="flex">
|
||||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={!grid}>
|
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed>
|
||||||
<List />
|
<List />
|
||||||
</span>
|
</span>
|
||||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={grid}>
|
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={false}>
|
||||||
<LayoutGrid />
|
<LayoutGrid />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -336,7 +303,7 @@ function Toolbar({ grid }: { grid: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
|
function Frame({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
|
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -348,21 +315,20 @@ function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
|
|||||||
Legújabb elöl <ChevronDown className="size-3.5" />
|
Legújabb elöl <ChevronDown className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Toolbar grid={grid} />
|
<Toolbar />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── F1: the list and the grid ────────────────────────────────────────────────────────────────────
|
// ── F1: the list ─────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the
|
// One grid for the whole list, each row a subgrid: every kind of value sits in its own column, so the
|
||||||
// badges, the sizes and the action icons line up down the page. An action that does not apply keeps
|
// badges, the sizes and the dates line up down the page.
|
||||||
// its slot empty (a file has no "copy the play link").
|
|
||||||
const LIST_COLUMNS =
|
const LIST_COLUMNS =
|
||||||
"grid grid-cols-[4rem_minmax(0,1fr)_7rem_6.5rem_7.5rem_4.5rem_4.5rem_auto] items-center gap-x-3";
|
"grid grid-cols-[4rem_minmax(0,1fr)_8rem_6.5rem_6.75rem_5.5rem_6.5rem_3rem] items-center gap-x-3";
|
||||||
const ROW = "col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5";
|
const ROW = "group col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5";
|
||||||
|
|
||||||
function ListHead() {
|
function ListHead() {
|
||||||
const th = "text-2xs uppercase tracking-wider text-muted";
|
const th = "text-2xs uppercase tracking-wider text-muted";
|
||||||
@@ -447,7 +413,7 @@ function ListRows() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-right text-xs tabular-nums text-muted">{item.size}</span>
|
<span className="text-right text-xs tabular-nums text-muted">{item.size}</span>
|
||||||
<span className="text-right text-xs text-muted">{item.when}</span>
|
<span className="text-right text-xs text-muted">{item.when}</span>
|
||||||
<RowActions item={item} />
|
<RowActions item={item} focused={i === 3} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -496,32 +462,6 @@ function RowMenu() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GridCards() {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
|
||||||
{ITEMS.map((item, i) => (
|
|
||||||
<div
|
|
||||||
key={item.name}
|
|
||||||
className={clsx(
|
|
||||||
"flex flex-col gap-1.5 rounded-xl p-1.5",
|
|
||||||
i === 3 && "bg-accent/8 ring-1 ring-accent/40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Thumb item={item} className="aspect-video w-full" clipOnPicture />
|
|
||||||
<span className="line-clamp-2 text-sm font-medium leading-5">{item.name}</span>
|
|
||||||
<span className="flex flex-wrap items-center gap-1 text-xs text-muted">
|
|
||||||
<SourceBadge item={item} />
|
|
||||||
{item.by && <span className="truncate">{item.by}</span>}
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
|
||||||
<StateBadges item={item} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── F2: the download tray ────────────────────────────────────────────────────────────────────────
|
// ── F2: the download tray ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) {
|
function TrayRow({ title, site, children }: { title: string; site: string; children: ReactNode }) {
|
||||||
@@ -536,13 +476,15 @@ function TrayRow({ title, site, children }: { title: string; site: string; child
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Progress({ pct }: { pct: number }) {
|
function Progress({ step, pct }: { step: string; pct: number }) {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2 text-xs text-muted">
|
<span className="flex items-center gap-2 text-xs text-muted">
|
||||||
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
|
<span className="h-1 flex-1 overflow-hidden rounded-full bg-fg/10">
|
||||||
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
</span>
|
</span>
|
||||||
<span className="w-8 text-right tabular-nums">{pct}%</span>
|
<span className="tabular-nums">
|
||||||
|
{step} · {pct}%
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -561,7 +503,7 @@ function TrayPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<TrayRow title="Ez a nap is eljött" site="tiktok.com">
|
<TrayRow title="Ez a nap is eljött" site="tiktok.com">
|
||||||
<Progress pct={64} />
|
<Progress step="Videó letöltése" pct={64} />
|
||||||
</TrayRow>
|
</TrayRow>
|
||||||
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
|
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
|
||||||
<span className="text-xs text-muted">Sorban áll…</span>
|
<span className="text-xs text-muted">Sorban áll…</span>
|
||||||
@@ -603,20 +545,6 @@ function TrayPill() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RailBell() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="relative grid size-10 place-items-center rounded-xl bg-accent/12 text-accent">
|
|
||||||
<Download className="size-5" />
|
|
||||||
<span className="absolute -right-1 -top-1 grid size-4 place-items-center rounded-full bg-accent text-2xs font-bold text-accent-fg">
|
|
||||||
3
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<TrayPanel />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── F3: the done notification ────────────────────────────────────────────────────────────────────
|
// ── F3: the done notification ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DoneToast() {
|
function DoneToast() {
|
||||||
@@ -647,84 +575,44 @@ function DoneToast() {
|
|||||||
export function FilesRound() {
|
export function FilesRound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Decision
|
<Reference
|
||||||
id="files-layout"
|
id="files-layout"
|
||||||
code="F1"
|
code="F1"
|
||||||
title="The Files page"
|
title="The Files page"
|
||||||
question="One library for everything: how does a folder show its items? Both have the same sidebar (sources, own folders, shared with me, storage), the paste box and the same badges: source + channel, phone-safe ✓, expiry when ≤7 days, kept. The highlighted item is the one just downloaded (focus on arrival)."
|
rule="One library for videos and plain files alike. List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar. Every kind of value in its own column (name, source, phone-safe, retention, size, added), one grid with a subgrid per row so they line up. Only the ⋯ holds a place: Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard (a card on a phone keeps them in its own line), and an action that does not apply keeps its slot empty. Every row has a right-click menu, the ⋯ opens the same one. The item just downloaded arrives focused (highlighted)."
|
||||||
now="Downloads › Library: one tall card per item, no source, no folders, a row of three icons; the paste box lives on the Queue tab."
|
|
||||||
>
|
>
|
||||||
<Variant
|
<Frame>
|
||||||
letter="A"
|
|
||||||
name="List rows (grid one click away)"
|
|
||||||
recommended
|
|
||||||
why="A library holds videos AND files (a hub report, a PDF): rows read both, show 12–15 items a screen, and put size and date in columns you can scan. The grid stays a toggle, remembered per folder, for a folder of clips."
|
|
||||||
>
|
|
||||||
<Frame grid={false}>
|
|
||||||
<ListRows />
|
<ListRows />
|
||||||
</Frame>
|
</Frame>
|
||||||
<Sample label="Right click on a row (the ⋯ opens the same menu)">
|
<Sample label="Right click on a row (the ⋯ opens the same menu)">
|
||||||
<RowMenu />
|
<RowMenu />
|
||||||
</Sample>
|
</Sample>
|
||||||
</Variant>
|
</Reference>
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="Media grid (list one click away)"
|
|
||||||
why="Pictures first, like the feed: best for a folder of clips, but a text file or a PDF becomes a big grey card, and 6–8 items fit a screen."
|
|
||||||
>
|
|
||||||
<Frame grid>
|
|
||||||
<GridCards />
|
|
||||||
</Frame>
|
|
||||||
</Variant>
|
|
||||||
</Decision>
|
|
||||||
|
|
||||||
<Decision
|
<Reference
|
||||||
id="files-tray"
|
id="files-tray"
|
||||||
code="F2"
|
code="F2"
|
||||||
title="Running downloads"
|
title="Running downloads"
|
||||||
question="Downloading is an action now, so where do the running and stuck ones show — on every page, not only in Files?"
|
rule="An app-wide tray, bottom right, shown only while something runs or failed: a failure stays with its reason and a retry, and it goes when all is done. A running row names its step beside its bar. It folds to a pill, so it never covers a player control."
|
||||||
now="the Queue tab of the Downloads page: you have to go there to see it."
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(22rem,1fr))] gap-4">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="A tray, bottom right (Drive)"
|
|
||||||
recommended
|
|
||||||
why="It appears by itself while something runs and goes when all is done; a failure stays with its reason and a retry. Folds to a pill, so it never covers a player control."
|
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
<Sample label="Open">
|
<Sample label="Open">
|
||||||
<TrayPanel />
|
<TrayPanel />
|
||||||
</Sample>
|
</Sample>
|
||||||
<Sample label="Folded">
|
<Sample label="Folded">
|
||||||
<TrayPill />
|
<TrayPill />
|
||||||
</Sample>
|
</Sample>
|
||||||
</Variant>
|
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="A rail icon with a count, the list in a popover"
|
|
||||||
why="Nothing floats over the page, but a running download is invisible until you look, and the rail grows a sixth always-there icon."
|
|
||||||
>
|
|
||||||
<RailBell />
|
|
||||||
</Variant>
|
|
||||||
</div>
|
</div>
|
||||||
</Decision>
|
</Reference>
|
||||||
|
|
||||||
<Decision
|
<Reference
|
||||||
id="files-done"
|
id="files-done"
|
||||||
code="F3"
|
code="F3"
|
||||||
title="When a download is done"
|
title="When a download is done"
|
||||||
question="The daily path is paste → done → copy the link → send. Can the notification finish it?"
|
rule="The notification says whether it plays on a phone and carries Link másolása and Megnyitás (its folder, the item focused), so paste → done → copy the link ends there. Never an automatic jump: it would pull you out of what you were doing."
|
||||||
now="a generic 'ready' notification; you open Library, check it plays, then press the share icon."
|
|
||||||
>
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="The notification carries the link"
|
|
||||||
recommended
|
|
||||||
why="It says whether it plays on a phone and copies the link in one click; Megnyitás opens its folder with the item focused. No jump on its own — it would pull you out of what you were doing."
|
|
||||||
>
|
>
|
||||||
<DoneToast />
|
<DoneToast />
|
||||||
</Variant>
|
</Reference>
|
||||||
</Decision>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useSyncExternalStore, type ReactNode } from "react";
|
import { useSyncExternalStore, type ReactNode } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Badge } from "../components/ui/Badge";
|
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
|
|
||||||
// Building blocks of the design lab. A taken decision is a <Reference>: the chosen rendering with the
|
// Building blocks of the design lab. Every decision in it is taken, so the lab is the reference half
|
||||||
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>s side
|
// only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
|
||||||
// by side, until the owner picks. Everything
|
// back a side-by-side <Decision>/<Variant> pair (see git history) for its open questions, and deletes
|
||||||
// is static markup on purpose: a rendering that only exists here must not need app state.
|
// it again once the owner picks. Everything is static markup on purpose: a rendering that only exists
|
||||||
|
// here must not need app state.
|
||||||
|
|
||||||
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
|
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
|
||||||
* reference half — the living style guide. */
|
* reference half — the living style guide. */
|
||||||
@@ -37,76 +37,6 @@ export function Reference({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An open question of a design round: what the app does today, then the variants side by side. */
|
|
||||||
export function Decision({
|
|
||||||
id,
|
|
||||||
code,
|
|
||||||
title,
|
|
||||||
question,
|
|
||||||
now,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
title: string;
|
|
||||||
question: string;
|
|
||||||
/** What the app does today. */
|
|
||||||
now: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section id={id} className={`${SURFACE.panel} p-5 scroll-mt-20`}>
|
|
||||||
<header className="mb-4">
|
|
||||||
<div className="flex items-baseline gap-3">
|
|
||||||
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
|
|
||||||
<h3 className="text-lg font-bold">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm">{question}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted">
|
|
||||||
<span className="font-semibold">Today: </span>
|
|
||||||
{now}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<div className="flex flex-col gap-4">{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One answer to a Decision; the recommended one carries the accent edge. */
|
|
||||||
export function Variant({
|
|
||||||
letter,
|
|
||||||
name,
|
|
||||||
recommended = false,
|
|
||||||
why,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
letter: string;
|
|
||||||
name: string;
|
|
||||||
recommended?: boolean;
|
|
||||||
why: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className={clsx(
|
|
||||||
SURFACE.card,
|
|
||||||
"flex flex-col gap-3 border p-4",
|
|
||||||
recommended ? "border-accent" : "border-transparent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-fg/8 text-xs font-bold">
|
|
||||||
{letter}
|
|
||||||
</span>
|
|
||||||
<h4 className="flex-1 text-sm font-semibold">{name}</h4>
|
|
||||||
{recommended && <Badge tone="accent">Recommended</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">{children}</div>
|
|
||||||
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A small caption above a sample inside a variant. */
|
/** A small caption above a sample inside a variant. */
|
||||||
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Decision, Sample, Variant } from "./labKit";
|
import { Reference, Sample } from "./labKit";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||||
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
||||||
|
|
||||||
// The folder links round: a /watch link that opens a whole folder, not one file. Static markup on
|
// Folder links, DECIDED (owner A/A/A): a /watch link that opens a whole folder, not one file. Static
|
||||||
// purpose (the lab's rule); the folder and its files are the owner's own kind of content.
|
// markup on purpose (the lab's rule); the folder and its files are the owner's own kind of content.
|
||||||
|
|
||||||
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
||||||
|
|
||||||
@@ -91,15 +91,13 @@ function Rail({ marked }: { marked: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderMenu({ quickCopy }: { quickCopy: boolean }) {
|
function FolderMenu() {
|
||||||
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
|
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
|
||||||
const groups: [typeof Folder, string, boolean?][][] = [
|
const groups: [typeof Folder, string, boolean?][][] = [
|
||||||
quickCopy
|
[
|
||||||
? [
|
|
||||||
[Link2, "Link másolása"],
|
[Link2, "Link másolása"],
|
||||||
[Users, "Megosztás linkkel…"],
|
[Users, "Megosztás linkkel…"],
|
||||||
]
|
],
|
||||||
: [[Users, "Megosztás linkkel…"]],
|
|
||||||
[
|
[
|
||||||
[Clock, "Megőrzés"],
|
[Clock, "Megőrzés"],
|
||||||
[Pencil, "Átnevezés"],
|
[Pencil, "Átnevezés"],
|
||||||
@@ -266,155 +264,54 @@ function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GridPage() {
|
|
||||||
return (
|
|
||||||
<PageFrame>
|
|
||||||
<Header />
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{FILES.map((f) => (
|
|
||||||
<div key={f.name} className="flex flex-col gap-1">
|
|
||||||
<Thumb file={f} className="aspect-video w-full" />
|
|
||||||
<span className="truncate text-sm">{f.name}</span>
|
|
||||||
<span className="flex items-center gap-1 text-xs text-muted">
|
|
||||||
<span className="flex-1">{f.size}</span>
|
|
||||||
<Download className="size-3.5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DownloadOnlyPage() {
|
|
||||||
return (
|
|
||||||
<PageFrame>
|
|
||||||
<Header />
|
|
||||||
<div className="flex flex-col divide-y divide-border/60">
|
|
||||||
{FILES.map((f) => (
|
|
||||||
<div key={f.name} className="flex items-center gap-3 py-2">
|
|
||||||
<FileText className="size-4 shrink-0 text-muted" />
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{f.name}</span>
|
|
||||||
<span className="text-xs text-muted tabular-nums">{f.size}</span>
|
|
||||||
<span className={buttonClass({ variant: "outline", size: "xs" })}>
|
|
||||||
<Download className="size-3.5" /> Letöltés
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The round ───────────────────────────────────────────────────────────────────────────────────
|
// ── The round ───────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function LinksRound() {
|
export function LinksRound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
<Decision
|
<Reference
|
||||||
id="open-links-l1"
|
id="links-l1"
|
||||||
code="L1"
|
code="L1"
|
||||||
title="Making a folder link"
|
title="Making a folder link"
|
||||||
question="A folder's right-click menu gets the share. Does it also get the one-click “Link másolása” a file row has, or only the dialog? The dialog is the file's Share dialog with only its link half (sharing with a user stays per file)."
|
rule="An own folder's right-click menu has Link másolása and Megosztás linkkel…, the two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it. The dialog is the file's Share dialog with only its link half (sharing with a user stays per file), and it says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
|
||||||
now="Only a file can be shared; a folder menu has Retention, Rename and Delete."
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="Copy link + Share with link…"
|
|
||||||
recommended
|
|
||||||
why="The same two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it."
|
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
<Sample label="the folder's right-click menu">
|
<Sample label="the folder's right-click menu">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Rail marked={false} />
|
<Rail marked={false} />
|
||||||
<FolderMenu quickCopy />
|
<FolderMenu />
|
||||||
</div>
|
</div>
|
||||||
</Sample>
|
</Sample>
|
||||||
</Variant>
|
<Sample label="the dialog Megosztás linkkel… opens">
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="Share with link… only"
|
|
||||||
why="One entry: every folder link goes through the dialog. One more click for the common case, nothing gained but a shorter menu."
|
|
||||||
>
|
|
||||||
<Sample label="the folder's right-click menu">
|
|
||||||
<FolderMenu quickCopy={false} />
|
|
||||||
</Sample>
|
|
||||||
</Variant>
|
|
||||||
<Variant
|
|
||||||
letter="A/B"
|
|
||||||
name="…the dialog either opens"
|
|
||||||
recommended
|
|
||||||
why="It says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
|
|
||||||
>
|
|
||||||
<FolderShareDialog />
|
<FolderShareDialog />
|
||||||
</Variant>
|
</Sample>
|
||||||
</div>
|
</div>
|
||||||
</Decision>
|
</Reference>
|
||||||
|
|
||||||
<Decision
|
<Reference
|
||||||
id="open-links-l2"
|
id="links-l2"
|
||||||
code="L2"
|
code="L2"
|
||||||
title="The page a folder link opens"
|
title="The page a folder link opens"
|
||||||
question="Signed out, often on a phone: the folder's name and its files. Each video plays right there, each file downloads (when the link allows it); a document is never opened as a web page, only downloaded."
|
rule="Signed out, often on a phone: the folder's name, then a list of its files as it is now. Play opens the watch page's player above the list with the playing row lit, the next one a tap away; each file downloads when the link allows it, and a document is never opened as a web page, only downloaded. A row without a picture keeps its glyph above the length."
|
||||||
now="A /watch link opens one file: its player, title and Download."
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="A list; Play opens the player above it"
|
|
||||||
recommended
|
|
||||||
why="The watch page's player, over a list you can read on a phone: a long name gets a full line, the playing row stays lit, the next one is one tap away. Mostly videos from a phone — this is the YouTube-playlist shape people know."
|
|
||||||
>
|
>
|
||||||
|
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,42rem)_auto]">
|
||||||
<Sample label="desktop, a video playing">
|
<Sample label="desktop, a video playing">
|
||||||
<ListPage playing />
|
<ListPage playing />
|
||||||
</Sample>
|
</Sample>
|
||||||
<Sample label="phone">
|
<Sample label="phone">
|
||||||
<ListPage phone />
|
<ListPage phone />
|
||||||
</Sample>
|
</Sample>
|
||||||
</Variant>
|
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="Cards"
|
|
||||||
why="Pictures first, like the Files grid. Nicer for a folder of photos-like clips, but names truncate to a line and a document is a grey card."
|
|
||||||
>
|
|
||||||
<GridPage />
|
|
||||||
</Variant>
|
|
||||||
<Variant
|
|
||||||
letter="C"
|
|
||||||
name="A download list only"
|
|
||||||
why="Like a file-transfer page: name, size, Letöltés. Simplest, but a friend has to download a clip to see it — on a phone, on mobile data."
|
|
||||||
>
|
|
||||||
<DownloadOnlyPage />
|
|
||||||
</Variant>
|
|
||||||
</div>
|
</div>
|
||||||
</Decision>
|
</Reference>
|
||||||
|
|
||||||
<Decision
|
<Reference
|
||||||
id="open-links-l3"
|
id="links-l3"
|
||||||
code="L3"
|
code="L3"
|
||||||
title="Seeing which folders are shared"
|
title="Seeing which folders are shared"
|
||||||
question="A folder link keeps showing whatever you put in the folder later. Does the rail mark a folder that has a live link, so you know where you drop files in public?"
|
rule="The rail marks a folder that has a live link with a quiet link icon beside its count: a folder link is the one place where moving a file in publishes it. The mark goes when the last link is revoked or runs out."
|
||||||
now="Nothing marks a shared file or folder."
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="A link mark beside the count"
|
|
||||||
recommended
|
|
||||||
why="A folder link is the one place where moving a file in publishes it. The mark is quiet (one small icon), and it goes when the last link is revoked or runs out."
|
|
||||||
>
|
>
|
||||||
<Rail marked />
|
<Rail marked />
|
||||||
</Variant>
|
</Reference>
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="No mark"
|
|
||||||
why="A cleaner rail. You learn a folder is shared only from its Share dialog."
|
|
||||||
>
|
|
||||||
<Rail marked={false} />
|
|
||||||
</Variant>
|
|
||||||
</div>
|
|
||||||
</Decision>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import clsx from "clsx";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
|
||||||
Copy,
|
Copy,
|
||||||
Folder,
|
Folder,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -12,15 +11,16 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Decision, Sample, Variant } from "./labKit";
|
import { Reference, Sample } from "./labKit";
|
||||||
import { Badge } from "../components/ui/Badge";
|
import { Badge } from "../components/ui/Badge";
|
||||||
import { Chip } from "../components/ui/Chip";
|
import { Chip } from "../components/ui/Chip";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||||
import { fieldClass } from "../components/ui/fieldStyles";
|
import { fieldClass } from "../components/ui/fieldStyles";
|
||||||
|
|
||||||
// The API tokens round: how a machine (the hub) gets a key to the Files library. Static markup on
|
// API tokens, DECIDED (owner A/A/A): how a machine (the hub) gets a key to the Files library. Static
|
||||||
// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script).
|
// markup on purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone
|
||||||
|
// script).
|
||||||
|
|
||||||
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -143,49 +143,6 @@ function ListRows() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListTable() {
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-xs">
|
|
||||||
<thead className="text-muted">
|
|
||||||
<tr className="border-b border-border/60">
|
|
||||||
<th className="py-1.5 pr-3 font-medium">Név</th>
|
|
||||||
<th className="py-1.5 pr-3 font-medium">Mappák</th>
|
|
||||||
<th className="py-1.5 pr-3 font-medium">Használva</th>
|
|
||||||
<th className="py-1.5 pr-3 font-medium">Lejár</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{TOKENS.map((tk) => (
|
|
||||||
<tr key={tk.prefix} className="border-b border-border/40 align-top">
|
|
||||||
<td className="py-2 pr-3">
|
|
||||||
<div className="text-sm font-medium">{tk.name}</div>
|
|
||||||
<code className="text-muted">{tk.prefix}…</code>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-3 text-muted">{tk.grants.map((g) => g.folder).join(", ")}</td>
|
|
||||||
<td className="py-2 pr-3 text-muted whitespace-nowrap">{tk.used}</td>
|
|
||||||
<td
|
|
||||||
className={clsx(
|
|
||||||
"py-2 pr-3 whitespace-nowrap",
|
|
||||||
tk.soon ? "text-warn" : "text-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tk.expires}
|
|
||||||
</td>
|
|
||||||
<td className="py-1">
|
|
||||||
<span className={ICON} aria-label="Műveletek">
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
|
// ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
@@ -220,22 +177,16 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Expiry({ never = false }: { never?: boolean }) {
|
function Expiry() {
|
||||||
return (
|
return (
|
||||||
<Field label="Lejárat">
|
<Field label="Lejárat">
|
||||||
<span className="flex flex-wrap gap-1">
|
<span className="flex flex-wrap gap-1">
|
||||||
{["30 nap", "90 nap", "1 év", "Soha"].map((l) => (
|
{["30 nap", "90 nap", "1 év", "Soha"].map((l) => (
|
||||||
<Chip key={l} pressed={never ? l === "Soha" : l === "1 év"} onClick={noop}>
|
<Chip key={l} pressed={l === "1 év"} onClick={noop}>
|
||||||
{l}
|
{l}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
{never && (
|
|
||||||
<span className="flex items-start gap-1.5 text-xs text-warn">
|
|
||||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
|
||||||
Egy soha le nem járó token addig nyitja a mappát, amíg vissza nem vonod.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -325,30 +276,6 @@ function PresetFirst({ custom }: { custom: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OneForm() {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title="Új API-token"
|
|
||||||
foot={
|
|
||||||
<>
|
|
||||||
<span className={buttonClass({ variant: "outline", size: "sm" })}>Mégse</span>
|
|
||||||
<span className={buttonClass({ variant: "primary", size: "sm" })}>Létrehozás</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Field label="Név">
|
|
||||||
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>Hub csere</span>
|
|
||||||
</Field>
|
|
||||||
<span className="flex items-center gap-2 text-xs text-muted">
|
|
||||||
Mappák és jogok
|
|
||||||
<span className={buttonClass({ variant: "link", size: "none" })}>Hub csere kitöltése</span>
|
|
||||||
</span>
|
|
||||||
<RightsMatrix />
|
|
||||||
<Expiry never />
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
|
// ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
|
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
|
||||||
@@ -396,120 +323,48 @@ function ShownInDialog() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShownAsBanner() {
|
|
||||||
return (
|
|
||||||
<SettingsFrame>
|
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-accent/50 bg-accent/6 p-3">
|
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium">
|
|
||||||
<Check className="size-4 text-success" />
|
|
||||||
„Hub csere” elkészült — másold ki most, később nem látod.
|
|
||||||
</span>
|
|
||||||
<CopyRow>{RAW}</CopyRow>
|
|
||||||
<span className={clsx(buttonClass({ variant: "link", size: "none" }), "self-start")}>
|
|
||||||
curl példa másolása
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ListRows />
|
|
||||||
</SettingsFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The round ───────────────────────────────────────────────────────────────────────────────────
|
// ── The round ───────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function TokensRound() {
|
export function TokensRound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
<Decision
|
<Reference
|
||||||
id="open-tokens-t1"
|
id="tokens-t1"
|
||||||
code="T1"
|
code="T1"
|
||||||
title="The token list (Settings → API tokens)"
|
title="The token list (Settings → API tokens)"
|
||||||
question="How does the tab list the tokens: what each opens, when it was last used, when it runs out? Revoke is in each row's “…” menu, behind a confirm."
|
rule="A row per token in three lines: name + prefix (the “soon expires” badge beside the name), then every folder with its rights, then last use and expiry — nothing truncated at the ~36rem Settings column, and it fits a phone as is. Revoke is in the row's ⋯ menu, behind a confirm."
|
||||||
now="There is no token UI; tokens exist only through the API."
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="Rows, three lines each"
|
|
||||||
recommended
|
|
||||||
why="The Settings column is ~36rem: a row reads name + prefix, then every folder with its rights, then last use and expiry — nothing truncated, and the “soon expires” badge sits beside the name. Fits a phone as is."
|
|
||||||
>
|
>
|
||||||
<SettingsFrame>
|
<SettingsFrame>
|
||||||
<ListRows />
|
<ListRows />
|
||||||
</SettingsFrame>
|
</SettingsFrame>
|
||||||
</Variant>
|
</Reference>
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="A table"
|
|
||||||
why="Scans in columns like the Download formats table, but at this width the folders column truncates to names (rights drop out) and a phone needs the card fallback anyway."
|
|
||||||
>
|
|
||||||
<SettingsFrame>
|
|
||||||
<ListTable />
|
|
||||||
</SettingsFrame>
|
|
||||||
</Variant>
|
|
||||||
</div>
|
|
||||||
</Decision>
|
|
||||||
|
|
||||||
<Decision
|
<Reference
|
||||||
id="open-tokens-t2"
|
id="tokens-t2"
|
||||||
code="T2"
|
code="T2"
|
||||||
title="Making a token"
|
title="Making a token"
|
||||||
question="How do you make one: the one-click Hub csere, or a token of your own folders and rights? Expiry: 30 days, 90 days, 1 year (default) or never (with a warning)."
|
rule="The dialog asks the kind first. Hub csere: one tile, the name filled in, Create — the common token never shows a matrix to get wrong. Egyedi: each folder a row, its rights as chips; a source folder has no Write (nothing can be uploaded into it, the server refuses it too). Expiry is a segmented control (DESIGN.md “A choice between a few values”): 30 days, 90 days, 1 year (the default) or never, which warns that the token opens the folder until revoked."
|
||||||
now="Nothing in the app."
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="Pick the kind first — Hub csere"
|
|
||||||
recommended
|
|
||||||
why="The hub is the case you have: one tile, the name filled in, Create. The rights grid only appears for “Egyedi”, so the common token never shows a matrix to get wrong."
|
|
||||||
>
|
>
|
||||||
|
<div className="grid items-start gap-4 xl:grid-cols-2">
|
||||||
|
<Sample label="Hub csere">
|
||||||
<PresetFirst custom={false} />
|
<PresetFirst custom={false} />
|
||||||
</Variant>
|
</Sample>
|
||||||
<Variant
|
<Sample label="Egyedi">
|
||||||
letter="A"
|
|
||||||
name="…the same, Egyedi"
|
|
||||||
recommended
|
|
||||||
why="Each folder a row, its rights as chips. A source folder has no Write: nothing can be uploaded into it (the server refuses it too)."
|
|
||||||
>
|
|
||||||
<PresetFirst custom />
|
<PresetFirst custom />
|
||||||
</Variant>
|
</Sample>
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="One form, a preset link"
|
|
||||||
why="Everything at once, with “Hub csere kitöltése” filling the grid. Fewer clicks for a custom token, but the hub case starts from a matrix. Shown with “never” picked, and its warning."
|
|
||||||
>
|
|
||||||
<OneForm />
|
|
||||||
</Variant>
|
|
||||||
</div>
|
</div>
|
||||||
</Decision>
|
</Reference>
|
||||||
|
|
||||||
<Decision
|
<Reference
|
||||||
id="open-tokens-t3"
|
id="tokens-t3"
|
||||||
code="T3"
|
code="T3"
|
||||||
title="The token, shown once"
|
title="The token, shown once"
|
||||||
question="Only its fingerprint is stored, so the token can be copied once. Where does it show, with a ready curl line to try it?"
|
rule="Only its fingerprint is stored, so the token shows once, as the dialog's last step: the token with a Copy, a curl line to try it, and Kész. Closing the dialog is plainly the moment it is gone."
|
||||||
now="Nothing in the app."
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
|
||||||
<Variant
|
|
||||||
letter="A"
|
|
||||||
name="The dialog's last step"
|
|
||||||
recommended
|
|
||||||
why="You are already looking at the dialog: the token, a Copy, a curl line to try it, and “Kész”. Closing is the moment it is gone, and it is plain that it is."
|
|
||||||
>
|
>
|
||||||
<Sample label="after Létrehozás">
|
<Sample label="after Létrehozás">
|
||||||
<ShownInDialog />
|
<ShownInDialog />
|
||||||
</Sample>
|
</Sample>
|
||||||
</Variant>
|
</Reference>
|
||||||
<Variant
|
|
||||||
letter="B"
|
|
||||||
name="A banner above the list"
|
|
||||||
why="The dialog closes and the token waits above the list until you leave the tab. The new row is right under it, but a banner is easy to scroll past — and leaving the tab loses it for good."
|
|
||||||
>
|
|
||||||
<ShownAsBanner />
|
|
||||||
</Variant>
|
|
||||||
</div>
|
|
||||||
</Decision>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
"linkCopied": "Share link copied to clipboard",
|
"linkCopied": "Share link copied to clipboard",
|
||||||
"openNewTab": "Open in new tab",
|
"openNewTab": "Open in new tab",
|
||||||
"linkFailed": "Couldn’t create the share link",
|
"linkFailed": "Couldn’t create the share link",
|
||||||
|
"linksFailed": "Couldn’t load the links",
|
||||||
"noLinks": "No links yet.",
|
"noLinks": "No links yet.",
|
||||||
"newLink": "Create a link",
|
"newLink": "Create a link",
|
||||||
"createLink": "Create link",
|
"createLink": "Create link",
|
||||||
|
|||||||
@@ -99,16 +99,16 @@
|
|||||||
"new": "New password (min. 10 characters)",
|
"new": "New password (min. 10 characters)",
|
||||||
"save": "Save password",
|
"save": "Save password",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saved": "Password updated for {{email}}",
|
"saved": "Password updated for {{email}} — other sessions were signed out and your API tokens revoked",
|
||||||
"failed": "Couldn't update the password"
|
"failed": "Couldn't update the password"
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"title": "Active sessions",
|
"title": "Active sessions",
|
||||||
"hint": "Sign this account out everywhere else — handy if you left it signed in on a shared or lost device. You stay signed in here.",
|
"hint": "Sign this account out everywhere else, and revoke its API tokens — handy if you left it signed in on a shared or lost device. You stay signed in here.",
|
||||||
"action": "Log out other sessions",
|
"action": "Log out other sessions",
|
||||||
"confirmTitle": "Log out other sessions?",
|
"confirmTitle": "Log out other sessions?",
|
||||||
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.",
|
"confirmBody": "This signs you out of every other browser and device, and revokes every API token of yours (make new ones for your machines). You'll stay signed in here.",
|
||||||
"done": "Signed out of all other sessions",
|
"done": "Signed out of all other sessions; API tokens revoked",
|
||||||
"failed": "Couldn't sign out other sessions. Please try again."
|
"failed": "Couldn't sign out other sessions. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -284,6 +284,7 @@
|
|||||||
"tryIt": "Try it (curl)",
|
"tryIt": "Try it (curl)",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
|
"copyField": "Copy: {{field}}",
|
||||||
"done": "Done"
|
"done": "Done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@
|
|||||||
"approvalWaitingOnYou": "waiting on you",
|
"approvalWaitingOnYou": "waiting on you",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copyFailed": "Couldn't copy — select the link and copy it by hand",
|
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"emailConfirmed": "confirmed",
|
"emailConfirmed": "confirmed",
|
||||||
"emailUnconfirmed": "not confirmed",
|
"emailUnconfirmed": "not confirmed",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
"linkCopied": "Megosztási link a vágólapra másolva",
|
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||||
"openNewTab": "Megnyitás új lapon",
|
"openNewTab": "Megnyitás új lapon",
|
||||||
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||||
|
"linksFailed": "Nem sikerült betölteni a linkeket",
|
||||||
"noLinks": "Még nincs link.",
|
"noLinks": "Még nincs link.",
|
||||||
"newLink": "Link létrehozása",
|
"newLink": "Link létrehozása",
|
||||||
"createLink": "Létrehozás",
|
"createLink": "Létrehozás",
|
||||||
|
|||||||
@@ -99,16 +99,16 @@
|
|||||||
"new": "Új jelszó (min. 10 karakter)",
|
"new": "Új jelszó (min. 10 karakter)",
|
||||||
"save": "Jelszó mentése",
|
"save": "Jelszó mentése",
|
||||||
"saving": "Mentés…",
|
"saving": "Mentés…",
|
||||||
"saved": "Jelszó frissítve ehhez: {{email}}",
|
"saved": "Jelszó frissítve ehhez: {{email}} — a többi munkamenet kijelentkezett, az API-tokenjeid visszavonva",
|
||||||
"failed": "Nem sikerült frissíteni a jelszót"
|
"failed": "Nem sikerült frissíteni a jelszót"
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"title": "Aktív munkamenetek",
|
"title": "Aktív munkamenetek",
|
||||||
"hint": "Jelentkeztesd ki ezt a fiókot minden más helyen — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.",
|
"hint": "Jelentkeztesd ki ezt a fiókot minden más helyen, és vond vissza az API-tokenjeit — hasznos, ha megosztott vagy elveszett eszközön maradt bejelentkezve. Itt bejelentkezve maradsz.",
|
||||||
"action": "Kijelentkezés máshonnan",
|
"action": "Kijelentkezés máshonnan",
|
||||||
"confirmTitle": "Kijelentkezés a többi munkamenetből?",
|
"confirmTitle": "Kijelentkezés a többi munkamenetből?",
|
||||||
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről. Itt bejelentkezve maradsz.",
|
"confirmBody": "Ez kijelentkeztet minden más böngészőből és eszközről, és visszavonja minden API-tokenedet (a gépeidnek újakat kell készítened). Itt bejelentkezve maradsz.",
|
||||||
"done": "Kijelentkeztetve minden más munkamenetből",
|
"done": "Kijelentkeztetve minden más munkamenetből; API-tokenek visszavonva",
|
||||||
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -284,6 +284,7 @@
|
|||||||
"tryIt": "Kipróbálás (curl)",
|
"tryIt": "Kipróbálás (curl)",
|
||||||
"copy": "Másolás",
|
"copy": "Másolás",
|
||||||
"copied": "Másolva",
|
"copied": "Másolva",
|
||||||
|
"copyField": "Másolás: {{field}}",
|
||||||
"done": "Kész"
|
"done": "Kész"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@
|
|||||||
"approvalWaitingOnYou": "rád vár",
|
"approvalWaitingOnYou": "rád vár",
|
||||||
"copied": "Másolva",
|
"copied": "Másolva",
|
||||||
"copy": "Másolás",
|
"copy": "Másolás",
|
||||||
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel",
|
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"emailConfirmed": "megerősítve",
|
"emailConfirmed": "megerősítve",
|
||||||
"emailUnconfirmed": "nincs megerősítve",
|
"emailUnconfirmed": "nincs megerősítve",
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import type { QueueJob } from "./api";
|
|||||||
import {
|
import {
|
||||||
clearFinished,
|
clearFinished,
|
||||||
forgetFinished,
|
forgetFinished,
|
||||||
noteAnnounced,
|
|
||||||
noteQueue,
|
noteQueue,
|
||||||
resetQueueWatch,
|
resetQueueWatch,
|
||||||
takeUnannounced,
|
takeUnannounced,
|
||||||
|
uploadEnded,
|
||||||
|
uploadStarted,
|
||||||
useFinished,
|
useFinished,
|
||||||
} from "./downloadQueue";
|
} from "./downloadQueue";
|
||||||
import { renderHook } from "@testing-library/react";
|
import { renderHook } from "@testing-library/react";
|
||||||
|
|
||||||
const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob;
|
const job = (id: number, status: string) => ({ id, status }) as unknown as QueueJob;
|
||||||
|
const upload = (id: number) =>
|
||||||
|
({ id, status: "done", source_kind: "upload" }) as unknown as QueueJob;
|
||||||
const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id);
|
const finishedIds = () => renderHook(() => useFinished()).result.current.map((j) => j.id);
|
||||||
|
|
||||||
beforeEach(resetQueueWatch);
|
beforeEach(resetQueueWatch);
|
||||||
@@ -44,12 +47,39 @@ describe("downloadQueue · a finish", () => {
|
|||||||
|
|
||||||
it("a finish its own action announced is kept for the tray but not announced again", () => {
|
it("a finish its own action announced is kept for the tray but not announced again", () => {
|
||||||
noteQueue([]);
|
noteQueue([]);
|
||||||
noteAnnounced(7); // an upload, done in the answer that made it
|
uploadStarted();
|
||||||
noteQueue([job(7, "done"), job(8, "done")]);
|
uploadEnded(7); // an upload, done in the answer that made it
|
||||||
|
noteQueue([upload(7), job(8, "done")]);
|
||||||
expect(takeUnannounced().map((j) => j.id)).toEqual([8]);
|
expect(takeUnannounced().map((j) => j.id)).toEqual([8]);
|
||||||
expect(finishedIds()).toEqual([7, 8]);
|
expect(finishedIds()).toEqual([7, 8]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an upload the queue sees done before its own answer arrives is still said once, by it", () => {
|
||||||
|
noteQueue([]);
|
||||||
|
uploadStarted();
|
||||||
|
noteQueue([upload(7)]); // the server committed it; the answer is still on its way
|
||||||
|
expect(takeUnannounced()).toEqual([]);
|
||||||
|
uploadEnded(7);
|
||||||
|
expect(takeUnannounced()).toEqual([]);
|
||||||
|
expect(finishedIds()).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("another device's upload landing meanwhile is announced once this tab's upload answers", () => {
|
||||||
|
noteQueue([]);
|
||||||
|
uploadStarted();
|
||||||
|
noteQueue([upload(7), upload(9)]);
|
||||||
|
uploadEnded(7);
|
||||||
|
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an upload that failed announced nothing: what landed meanwhile is announced", () => {
|
||||||
|
noteQueue([]);
|
||||||
|
uploadStarted();
|
||||||
|
noteQueue([upload(9)]);
|
||||||
|
uploadEnded(null);
|
||||||
|
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
|
||||||
|
});
|
||||||
|
|
||||||
it("a failure is not a finish; closing the tray forgets what finished", () => {
|
it("a failure is not a finish; closing the tray forgets what finished", () => {
|
||||||
noteQueue([job(1, "running"), job(2, "running")]);
|
noteQueue([job(1, "running"), job(2, "running")]);
|
||||||
noteQueue([job(1, "error"), job(2, "done")]);
|
noteQueue([job(1, "error"), job(2, "done")]);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const unannounced = createStore<QueueJob[]>([]);
|
|||||||
const revealing = createStore<Reveal | null>(null);
|
const revealing = createStore<Reveal | null>(null);
|
||||||
// Finishes this tab already said out loud where they happened: an upload that answered done.
|
// Finishes this tab already said out loud where they happened: an upload that answered done.
|
||||||
const announcedHere = new Set<number>();
|
const announcedHere = new Set<number>();
|
||||||
|
// This tab's uploads still waiting for their answer, and the uploads the queue saw land done
|
||||||
|
// meanwhile: one of them may be the answer's own job, done before the answer arrived. Whose it is,
|
||||||
|
// only the answer can tell (`uploadEnded`), so until then none of them is announced.
|
||||||
|
let uploading = 0;
|
||||||
|
let held: QueueJob[] = [];
|
||||||
|
|
||||||
/** A job to show in the tray, asked for at `at` (ms). */
|
/** A job to show in the tray, asked for at `at` (ms). */
|
||||||
export interface Reveal {
|
export interface Reveal {
|
||||||
@@ -54,15 +59,35 @@ export function noteQueue(jobs: QueueJob[]): void {
|
|||||||
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
|
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
|
||||||
// `delete` answers whether it was there: each id is spent on the one look that finds it done.
|
// `delete` answers whether it was there: each id is spent on the one look that finds it done.
|
||||||
const news = done.filter((j) => !announcedHere.delete(j.id));
|
const news = done.filter((j) => !announcedHere.delete(j.id));
|
||||||
if (news.length) unannounced.set((list) => [...list, ...news]);
|
const landed = (j: QueueJob) =>
|
||||||
|
uploading > 0 && j.source_kind === "upload" && before.get(j.id) === undefined;
|
||||||
|
held = [...held, ...news.filter(landed)];
|
||||||
|
const now = news.filter((j) => !landed(j));
|
||||||
|
if (now.length) unannounced.set((list) => [...list, ...now]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A finish its own action announced already (an upload done in the answer that made it): the tray
|
/** An upload from this tab is on its way: what the queue sees land done may be its own job. */
|
||||||
* still keeps it, but the done notice would say it a second time — and as a download. */
|
export const uploadStarted = (): void => {
|
||||||
export const noteAnnounced = (id: number): void => {
|
uploading += 1;
|
||||||
announcedHere.add(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** An upload's answer came: `said` is the job it announced itself (done in that answer — the tray
|
||||||
|
* still keeps it, but its done notice would say it a second time, and as a download), null when it
|
||||||
|
* announced none (still converting, failed, cancelled). In whichever order the queue saw it. The
|
||||||
|
* rest held meanwhile (another device's uploads) are announced once no upload is waiting. */
|
||||||
|
export function uploadEnded(said: number | null): void {
|
||||||
|
uploading = Math.max(0, uploading - 1);
|
||||||
|
if (said !== null) {
|
||||||
|
const before = held.length;
|
||||||
|
held = held.filter((j) => j.id !== said);
|
||||||
|
if (held.length === before) announcedHere.add(said);
|
||||||
|
}
|
||||||
|
if (uploading > 0 || held.length === 0) return;
|
||||||
|
const release = held;
|
||||||
|
held = [];
|
||||||
|
unannounced.set((list) => [...list, ...release]);
|
||||||
|
}
|
||||||
|
|
||||||
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
|
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
|
||||||
export async function fetchQueue(): Promise<QueueJob[]> {
|
export async function fetchQueue(): Promise<QueueJob[]> {
|
||||||
const jobs = await api.downloads();
|
const jobs = await api.downloads();
|
||||||
@@ -107,5 +132,7 @@ export function resetQueueWatch(): void {
|
|||||||
finished.set([]);
|
finished.set([]);
|
||||||
unannounced.set([]);
|
unannounced.set([]);
|
||||||
announcedHere.clear();
|
announcedHere.clear();
|
||||||
|
uploading = 0;
|
||||||
|
held = [];
|
||||||
revealing.set(null);
|
revealing.set(null);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user