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:
@@ -9,13 +9,13 @@ import { displayName } from "../lib/api/account";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
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
|
||||
// one account lives.
|
||||
@@ -59,7 +59,7 @@ export default function AccountPanel({
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
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({
|
||||
queryKey: qk.adminUserAccess(user.id),
|
||||
@@ -88,19 +88,11 @@ export default function AccountPanel({
|
||||
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
|
||||
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
|
||||
// minimal self-host this no-email link was built for.
|
||||
if (await copyText(link.url)) {
|
||||
setCopied(true);
|
||||
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") });
|
||||
// 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
|
||||
// minimal self-host this no-email link was built for.
|
||||
const copy = () => {
|
||||
if (link) void copyValue(link.url);
|
||||
};
|
||||
|
||||
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.
|
||||
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" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -14,11 +14,9 @@ import {
|
||||
type TokenRight,
|
||||
} from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { folderIcon, folderLabel, takesUploads } from "./fileFolders";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
@@ -37,6 +35,7 @@ import { RelativeTime } from "./ui/RelativeTime";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
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,
|
||||
// "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;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
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") });
|
||||
};
|
||||
const { copied, copy } = useCopyFeedback();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted">{label}</span>
|
||||
@@ -499,7 +489,9 @@ function CopyField({
|
||||
size="sm"
|
||||
autoFocus={autoFocus}
|
||||
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")}
|
||||
</Button>
|
||||
|
||||
@@ -177,6 +177,27 @@ describe("ShareDialog · a folder", () => {
|
||||
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 () => {
|
||||
h.folderLinks.mockReset().mockResolvedValue([]);
|
||||
h.createFolderLink.mockReset().mockResolvedValue(link(6));
|
||||
|
||||
@@ -7,7 +7,6 @@ import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
|
||||
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 { SURFACE } from "./ui/surfaces";
|
||||
import UserPicker from "./UserPicker";
|
||||
import { useCopyFeedback } from "./useCopyFeedback";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
@@ -68,18 +69,13 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { copied, copy: copyValue } = useCopyFeedback();
|
||||
const [editPass, setEditPass] = useState(false);
|
||||
const [newPass, setNewPass] = useState("");
|
||||
const fullUrl = link.url && window.location.origin + link.url;
|
||||
|
||||
const copy = async () => {
|
||||
if (fullUrl && (await copyText(fullUrl))) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
notify({ level: "error", message: t("downloads.share.copyFailed") });
|
||||
}
|
||||
const copy = () => {
|
||||
if (fullUrl) void copyValue(fullUrl);
|
||||
};
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||
@@ -299,16 +295,28 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(links.data ?? []).map((l) => (
|
||||
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||
))}
|
||||
{links.data && links.data.length === 0 && !creating && !create.isPending && (
|
||||
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* 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">
|
||||
{links.data.map((l) => (
|
||||
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||
))}
|
||||
{links.data.length === 0 && !creating && !create.isPending && (
|
||||
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating ? (
|
||||
{!links.data ? null : creating ? (
|
||||
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
|
||||
<CheckRow checked={allowDownload} onChange={setAllowDownload} className="text-sm">
|
||||
{t("downloads.share.allowDownload")}
|
||||
|
||||
@@ -16,7 +16,8 @@ const h = vi.hoisted(() => ({
|
||||
filesUsage: vi.fn(async () => ({ upload_max_bytes: 1_000_000 })),
|
||||
notify: vi.fn(),
|
||||
showInTray: vi.fn(),
|
||||
noteAnnounced: vi.fn(),
|
||||
uploadStarted: vi.fn(),
|
||||
uploadEnded: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -38,7 +39,8 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
vi.mock("../lib/downloadQueue", () => ({
|
||||
showInTray: h.showInTray,
|
||||
noteAnnounced: h.noteAnnounced,
|
||||
uploadStarted: h.uploadStarted,
|
||||
uploadEnded: h.uploadEnded,
|
||||
}));
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
@@ -117,7 +119,7 @@ describe("UploadDialog", () => {
|
||||
message: "downloads.upload.addedConverting",
|
||||
});
|
||||
// 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 () => {
|
||||
@@ -132,7 +134,8 @@ describe("UploadDialog", () => {
|
||||
expect(h.openFile).toHaveBeenLastCalledWith(42, 5);
|
||||
expect(h.showInTray).not.toHaveBeenCalled();
|
||||
// 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();
|
||||
h.notify.mockClear();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isAborted } from "../lib/api/core";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
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 { useFilesActions } from "./filesContext";
|
||||
import { formatBytes } from "../lib/format";
|
||||
@@ -28,6 +28,8 @@ import { Input } from "./ui/Input";
|
||||
// skip the whole dialog.
|
||||
const uploadedKey = (status: string) =>
|
||||
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({
|
||||
folderId = null,
|
||||
initialFile = null,
|
||||
@@ -96,6 +98,7 @@ export default function UploadDialog({
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
const displayName = name.trim() || undefined;
|
||||
uploadStarted();
|
||||
try {
|
||||
const job = await api.uploadFile(file, {
|
||||
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
|
||||
// to announce when it is done.
|
||||
if (job.status === "done") noteAnnounced(job.id);
|
||||
uploadEnded(announces(job));
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
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
|
||||
// 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.
|
||||
uploadEnded(null);
|
||||
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
|
||||
// Re-arm the form so the user can retry or pick again.
|
||||
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>
|
||||
<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">
|
||||
Tokens
|
||||
<a href="#open-links" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||
Links ✓
|
||||
</a>
|
||||
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
|
||||
Files
|
||||
<a href="#open-tokens" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||
Tokens ✓
|
||||
</a>
|
||||
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||
Files ✓
|
||||
</a>
|
||||
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||
A ✓
|
||||
@@ -109,23 +112,25 @@ export default function DesignLab() {
|
||||
<header id="open-links" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · Folder links</h2>
|
||||
<p className="text-sm text-muted">
|
||||
A /watch link that opens a whole folder. Picked: A, A, A — the variants with the accent
|
||||
edge (built; on the public list a picture-less row keeps its glyph above the length).
|
||||
A /watch link that opens a whole folder. Picked: Copy link beside Share with link… in
|
||||
the folder menu, a list the link opens with the player above it, a link mark in the
|
||||
rail.
|
||||
</p>
|
||||
</header>
|
||||
<LinksRound />
|
||||
<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>
|
||||
<p className="text-sm text-muted">
|
||||
Settings → API tokens. Picked: A, A, A — the variants with the accent edge (built; the
|
||||
expiry is a segmented control, DESIGN.md "A choice between a few values").
|
||||
Settings → API tokens. Picked: a row per token in three lines, the kind asked first (Hub
|
||||
csere or Egyedi, rights as chips per folder), the token shown once in the same dialog.
|
||||
</p>
|
||||
</header>
|
||||
<TokensRound />
|
||||
<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">
|
||||
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>
|
||||
</header>
|
||||
<FilesRound />
|
||||
|
||||
@@ -34,15 +34,16 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { Decision, Sample, Variant } from "./labKit";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import { Badge } from "../components/ui/Badge";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||
import { fieldClass } from "../components/ui/fieldStyles";
|
||||
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
||||
|
||||
// The Files round: the one library that replaces the Downloads page. Static markup on purpose (the
|
||||
// lab's rule): the sample rows are the owner's own kind of content, nothing here talks to the server.
|
||||
// Files, the one library, DECIDED (owner, 2026-10-11): the page that replaced Downloads. Static markup
|
||||
// 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" });
|
||||
|
||||
@@ -158,47 +159,7 @@ function SourceBadge({ item }: { item: Item }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StateBadges({ item }: { item: Item }) {
|
||||
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;
|
||||
}) {
|
||||
function Thumb({ item, className }: { item: Item; className: string }) {
|
||||
const Glyph = item.kind === "video" ? null : FileText;
|
||||
return (
|
||||
<div
|
||||
@@ -210,31 +171,37 @@ function Thumb({
|
||||
>
|
||||
{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>}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
{item.kind === "video" ? (
|
||||
<span className={ICON} title="Link másolása">
|
||||
<Link />
|
||||
<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" ? (
|
||||
<span className={ICON} title="Link másolása">
|
||||
<Link />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-7" aria-hidden />
|
||||
)}
|
||||
<span className={ICON} title="Megosztás">
|
||||
<Share2 />
|
||||
</span>
|
||||
<span className={ICON} title="Letöltés">
|
||||
<Download />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-7" aria-hidden />
|
||||
)}
|
||||
<span className={ICON} title="Megosztás">
|
||||
<Share2 />
|
||||
</span>
|
||||
<span className={ICON} title="Letöltés">
|
||||
<Download />
|
||||
</span>
|
||||
<span className={ICON} title="Továbbiak">
|
||||
<MoreHorizontal />
|
||||
@@ -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({
|
||||
icon: Icon,
|
||||
@@ -303,7 +270,7 @@ function Sidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ grid }: { grid: boolean }) {
|
||||
function Toolbar() {
|
||||
return (
|
||||
<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")}>
|
||||
@@ -325,10 +292,10 @@ function Toolbar({ grid }: { grid: boolean }) {
|
||||
Feltöltés
|
||||
</span>
|
||||
<span className="flex">
|
||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={!grid}>
|
||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed>
|
||||
<List />
|
||||
</span>
|
||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={grid}>
|
||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={false}>
|
||||
<LayoutGrid />
|
||||
</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 (
|
||||
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
|
||||
<Sidebar />
|
||||
@@ -348,21 +315,20 @@ function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
|
||||
Legújabb elöl <ChevronDown className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<Toolbar grid={grid} />
|
||||
<Toolbar />
|
||||
{children}
|
||||
</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
|
||||
// badges, the sizes and the action icons line up down the page. An action that does not apply keeps
|
||||
// its slot empty (a file has no "copy the play link").
|
||||
// badges, the sizes and the dates line up down the page.
|
||||
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";
|
||||
const ROW = "col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5";
|
||||
"grid grid-cols-[4rem_minmax(0,1fr)_8rem_6.5rem_6.75rem_5.5rem_6.5rem_3rem] items-center gap-x-3";
|
||||
const ROW = "group col-span-full grid grid-cols-subgrid items-center rounded-lg px-2 py-1.5";
|
||||
|
||||
function ListHead() {
|
||||
const th = "text-2xs uppercase tracking-wider text-muted";
|
||||
@@ -447,7 +413,7 @@ function ListRows() {
|
||||
</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>
|
||||
<RowActions item={item} />
|
||||
<RowActions item={item} focused={i === 3} />
|
||||
</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 ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<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="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
<span className="w-8 text-right tabular-nums">{pct}%</span>
|
||||
<span className="tabular-nums">
|
||||
{step} · {pct}%
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -561,7 +503,7 @@ function TrayPanel() {
|
||||
</span>
|
||||
</div>
|
||||
<TrayRow title="Ez a nap is eljött" site="tiktok.com">
|
||||
<Progress pct={64} />
|
||||
<Progress step="Videó letöltése" pct={64} />
|
||||
</TrayRow>
|
||||
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
|
||||
<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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DoneToast() {
|
||||
@@ -647,84 +575,44 @@ function DoneToast() {
|
||||
export function FilesRound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Decision
|
||||
<Reference
|
||||
id="files-layout"
|
||||
code="F1"
|
||||
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)."
|
||||
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."
|
||||
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)."
|
||||
>
|
||||
<Variant
|
||||
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 />
|
||||
</Frame>
|
||||
<Sample label="Right click on a row (the ⋯ opens the same menu)">
|
||||
<RowMenu />
|
||||
</Sample>
|
||||
</Variant>
|
||||
<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>
|
||||
<Frame>
|
||||
<ListRows />
|
||||
</Frame>
|
||||
<Sample label="Right click on a row (the ⋯ opens the same menu)">
|
||||
<RowMenu />
|
||||
</Sample>
|
||||
</Reference>
|
||||
|
||||
<Decision
|
||||
<Reference
|
||||
id="files-tray"
|
||||
code="F2"
|
||||
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?"
|
||||
now="the Queue tab of the Downloads page: you have to go there to see it."
|
||||
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."
|
||||
>
|
||||
<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."
|
||||
>
|
||||
<Sample label="Open">
|
||||
<TrayPanel />
|
||||
</Sample>
|
||||
<Sample label="Folded">
|
||||
<TrayPill />
|
||||
</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 className="flex flex-wrap items-start gap-4">
|
||||
<Sample label="Open">
|
||||
<TrayPanel />
|
||||
</Sample>
|
||||
<Sample label="Folded">
|
||||
<TrayPill />
|
||||
</Sample>
|
||||
</div>
|
||||
</Decision>
|
||||
</Reference>
|
||||
|
||||
<Decision
|
||||
<Reference
|
||||
id="files-done"
|
||||
code="F3"
|
||||
title="When a download is done"
|
||||
question="The daily path is paste → done → copy the link → send. Can the notification finish it?"
|
||||
now="a generic 'ready' notification; you open Library, check it plays, then press the share icon."
|
||||
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."
|
||||
>
|
||||
<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 />
|
||||
</Variant>
|
||||
</Decision>
|
||||
<DoneToast />
|
||||
</Reference>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useSyncExternalStore, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Badge } from "../components/ui/Badge";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
|
||||
// Building blocks of the design lab. A taken decision is a <Reference>: the chosen rendering with the
|
||||
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>s side
|
||||
// by side, until the owner picks. Everything
|
||||
// is static markup on purpose: a rendering that only exists here must not need app state.
|
||||
// Building blocks of the design lab. Every decision in it is taken, so the lab is the reference half
|
||||
// only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
|
||||
// back a side-by-side <Decision>/<Variant> pair (see git history) for its open questions, and deletes
|
||||
// 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
|
||||
* 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. */
|
||||
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Decision, Sample, Variant } from "./labKit";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||
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
|
||||
// purpose (the lab's rule); the folder and its files are the owner's own kind of content.
|
||||
// Folder links, DECIDED (owner A/A/A): a /watch link that opens a whole folder, not one file. Static
|
||||
// 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" });
|
||||
|
||||
@@ -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 groups: [typeof Folder, string, boolean?][][] = [
|
||||
quickCopy
|
||||
? [
|
||||
[Link2, "Link másolása"],
|
||||
[Users, "Megosztás linkkel…"],
|
||||
]
|
||||
: [[Users, "Megosztás linkkel…"]],
|
||||
[
|
||||
[Link2, "Link másolása"],
|
||||
[Users, "Megosztás linkkel…"],
|
||||
],
|
||||
[
|
||||
[Clock, "Megőrzé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 ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function LinksRound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Decision
|
||||
id="open-links-l1"
|
||||
<div className="flex flex-col gap-4">
|
||||
<Reference
|
||||
id="links-l1"
|
||||
code="L1"
|
||||
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)."
|
||||
now="Only a file can be shared; a folder menu has Retention, Rename and Delete."
|
||||
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."
|
||||
>
|
||||
<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."
|
||||
>
|
||||
<Sample label="the folder's right-click menu">
|
||||
<div className="flex gap-3">
|
||||
<Rail marked={false} />
|
||||
<FolderMenu quickCopy />
|
||||
</div>
|
||||
</Sample>
|
||||
</Variant>
|
||||
<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."
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<Sample label="the folder's right-click menu">
|
||||
<div className="flex gap-3">
|
||||
<Rail marked={false} />
|
||||
<FolderMenu />
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="the dialog Megosztás linkkel… opens">
|
||||
<FolderShareDialog />
|
||||
</Variant>
|
||||
</Sample>
|
||||
</div>
|
||||
</Decision>
|
||||
</Reference>
|
||||
|
||||
<Decision
|
||||
id="open-links-l2"
|
||||
<Reference
|
||||
id="links-l2"
|
||||
code="L2"
|
||||
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."
|
||||
now="A /watch link opens one file: its player, title and Download."
|
||||
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."
|
||||
>
|
||||
<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."
|
||||
>
|
||||
<Sample label="desktop, a video playing">
|
||||
<ListPage playing />
|
||||
</Sample>
|
||||
<Sample label="phone">
|
||||
<ListPage phone />
|
||||
</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 className="grid items-start gap-4 xl:grid-cols-[minmax(0,42rem)_auto]">
|
||||
<Sample label="desktop, a video playing">
|
||||
<ListPage playing />
|
||||
</Sample>
|
||||
<Sample label="phone">
|
||||
<ListPage phone />
|
||||
</Sample>
|
||||
</div>
|
||||
</Decision>
|
||||
</Reference>
|
||||
|
||||
<Decision
|
||||
id="open-links-l3"
|
||||
<Reference
|
||||
id="links-l3"
|
||||
code="L3"
|
||||
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?"
|
||||
now="Nothing marks a shared file or folder."
|
||||
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."
|
||||
>
|
||||
<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 />
|
||||
</Variant>
|
||||
<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>
|
||||
<Rail marked />
|
||||
</Reference>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Folder,
|
||||
KeyRound,
|
||||
@@ -12,15 +11,16 @@ import {
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Decision, Sample, Variant } from "./labKit";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import { Badge } from "../components/ui/Badge";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||
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
|
||||
// purpose (the lab's rule); the sample tokens are the owner's own kind (the hub, a phone script).
|
||||
// API tokens, DECIDED (owner A/A/A): how a machine (the hub) gets a key to the Files library. Static
|
||||
// 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 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Dialog({
|
||||
@@ -220,22 +177,16 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Expiry({ never = false }: { never?: boolean }) {
|
||||
function Expiry() {
|
||||
return (
|
||||
<Field label="Lejárat">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{["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}
|
||||
</Chip>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TokensRound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Decision
|
||||
id="open-tokens-t1"
|
||||
<div className="flex flex-col gap-4">
|
||||
<Reference
|
||||
id="tokens-t1"
|
||||
code="T1"
|
||||
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."
|
||||
now="There is no token UI; tokens exist only through the API."
|
||||
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."
|
||||
>
|
||||
<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>
|
||||
<ListRows />
|
||||
</SettingsFrame>
|
||||
</Variant>
|
||||
<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>
|
||||
<SettingsFrame>
|
||||
<ListRows />
|
||||
</SettingsFrame>
|
||||
</Reference>
|
||||
|
||||
<Decision
|
||||
id="open-tokens-t2"
|
||||
<Reference
|
||||
id="tokens-t2"
|
||||
code="T2"
|
||||
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)."
|
||||
now="Nothing in the app."
|
||||
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."
|
||||
>
|
||||
<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} />
|
||||
</Variant>
|
||||
<Variant
|
||||
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)."
|
||||
>
|
||||
</Sample>
|
||||
<Sample label="Egyedi">
|
||||
<PresetFirst custom />
|
||||
</Variant>
|
||||
<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>
|
||||
</Sample>
|
||||
</div>
|
||||
</Decision>
|
||||
</Reference>
|
||||
|
||||
<Decision
|
||||
id="open-tokens-t3"
|
||||
<Reference
|
||||
id="tokens-t3"
|
||||
code="T3"
|
||||
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?"
|
||||
now="Nothing in the app."
|
||||
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."
|
||||
>
|
||||
<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">
|
||||
<ShownInDialog />
|
||||
</Sample>
|
||||
</Variant>
|
||||
<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>
|
||||
<Sample label="after Létrehozás">
|
||||
<ShownInDialog />
|
||||
</Sample>
|
||||
</Reference>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"linkCopied": "Share link copied to clipboard",
|
||||
"openNewTab": "Open in new tab",
|
||||
"linkFailed": "Couldn’t create the share link",
|
||||
"linksFailed": "Couldn’t load the links",
|
||||
"noLinks": "No links yet.",
|
||||
"newLink": "Create a link",
|
||||
"createLink": "Create link",
|
||||
|
||||
@@ -99,16 +99,16 @@
|
||||
"new": "New password (min. 10 characters)",
|
||||
"save": "Save password",
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"confirmTitle": "Log out other sessions?",
|
||||
"confirmBody": "This signs you out of every other browser and device. You'll stay signed in here.",
|
||||
"done": "Signed out of all other sessions",
|
||||
"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; API tokens revoked",
|
||||
"failed": "Couldn't sign out other sessions. Please try again."
|
||||
}
|
||||
},
|
||||
@@ -284,6 +284,7 @@
|
||||
"tryIt": "Try it (curl)",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyField": "Copy: {{field}}",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"approvalWaitingOnYou": "waiting on you",
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
"copyFailed": "Couldn't copy — select the link and copy it by hand",
|
||||
"email": "Email",
|
||||
"emailConfirmed": "confirmed",
|
||||
"emailUnconfirmed": "not confirmed",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||
"openNewTab": "Megnyitás új lapon",
|
||||
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||
"linksFailed": "Nem sikerült betölteni a linkeket",
|
||||
"noLinks": "Még nincs link.",
|
||||
"newLink": "Link létrehozása",
|
||||
"createLink": "Létrehozás",
|
||||
|
||||
@@ -99,16 +99,16 @@
|
||||
"new": "Új jelszó (min. 10 karakter)",
|
||||
"save": "Jelszó mentése",
|
||||
"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"
|
||||
},
|
||||
"sessions": {
|
||||
"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",
|
||||
"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.",
|
||||
"done": "Kijelentkeztetve minden más munkamenetből",
|
||||
"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; API-tokenek visszavonva",
|
||||
"failed": "Nem sikerült kijelentkeztetni a többi munkamenetet. Próbáld újra."
|
||||
}
|
||||
},
|
||||
@@ -284,6 +284,7 @@
|
||||
"tryIt": "Kipróbálás (curl)",
|
||||
"copy": "Másolás",
|
||||
"copied": "Másolva",
|
||||
"copyField": "Másolás: {{field}}",
|
||||
"done": "Kész"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
"approvalWaitingOnYou": "rád vár",
|
||||
"copied": "Másolva",
|
||||
"copy": "Másolás",
|
||||
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel",
|
||||
"email": "E-mail",
|
||||
"emailConfirmed": "megerősítve",
|
||||
"emailUnconfirmed": "nincs megerősítve",
|
||||
|
||||
@@ -7,15 +7,18 @@ import type { QueueJob } from "./api";
|
||||
import {
|
||||
clearFinished,
|
||||
forgetFinished,
|
||||
noteAnnounced,
|
||||
noteQueue,
|
||||
resetQueueWatch,
|
||||
takeUnannounced,
|
||||
uploadEnded,
|
||||
uploadStarted,
|
||||
useFinished,
|
||||
} from "./downloadQueue";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
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);
|
||||
|
||||
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", () => {
|
||||
noteQueue([]);
|
||||
noteAnnounced(7); // an upload, done in the answer that made it
|
||||
noteQueue([job(7, "done"), job(8, "done")]);
|
||||
uploadStarted();
|
||||
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(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", () => {
|
||||
noteQueue([job(1, "running"), job(2, "running")]);
|
||||
noteQueue([job(1, "error"), job(2, "done")]);
|
||||
|
||||
@@ -35,6 +35,11 @@ const unannounced = createStore<QueueJob[]>([]);
|
||||
const revealing = createStore<Reveal | null>(null);
|
||||
// Finishes this tab already said out loud where they happened: an upload that answered done.
|
||||
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). */
|
||||
export interface Reveal {
|
||||
@@ -54,15 +59,35 @@ export function noteQueue(jobs: QueueJob[]): void {
|
||||
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.
|
||||
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
|
||||
* still keeps it, but the done notice would say it a second time — and as a download. */
|
||||
export const noteAnnounced = (id: number): void => {
|
||||
announcedHere.add(id);
|
||||
/** An upload from this tab is on its way: what the queue sees land done may be its own job. */
|
||||
export const uploadStarted = (): void => {
|
||||
uploading += 1;
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
export async function fetchQueue(): Promise<QueueJob[]> {
|
||||
const jobs = await api.downloads();
|
||||
@@ -107,5 +132,7 @@ export function resetQueueWatch(): void {
|
||||
finished.set([]);
|
||||
unannounced.set([]);
|
||||
announcedHere.clear();
|
||||
uploading = 0;
|
||||
held = [];
|
||||
revealing.set(null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user