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:
2026-10-15 04:58:17 +02:00
parent 827df7494c
commit 2efa7d5a75
23 changed files with 505 additions and 773 deletions
+2 -1
View File
@@ -662,7 +662,7 @@ of a full-width panel and nothing on the right.
## Decision log
| 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 | 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 |
@@ -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 | 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-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 |
+4 -12
View File
@@ -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") });
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();
});
+5 -13
View File
@@ -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));
+20 -12
View File
@@ -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>
{/* 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) => (
{links.data.map((l) => (
<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>
)}
</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();
+6 -2
View File
@@ -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 };
}
+15 -10
View File
@@ -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&apos;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 />
+46 -158
View File
@@ -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,19 +171,24 @@ 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">
<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 />
@@ -236,6 +202,7 @@ function RowActions({ item }: { item: Item }) {
<span className={ICON} title="Letöltés">
<Download />
</span>
</span>
<span className={ICON} title="Továbbiak">
<MoreHorizontal />
</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({
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}>
<Frame>
<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>
</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."
>
<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."
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="flex flex-wrap items-start gap-4">
<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>
</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."
>
<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."
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."
>
<DoneToast />
</Variant>
</Decision>
</Reference>
</div>
);
}
+5 -75
View File
@@ -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 (
+24 -127
View File
@@ -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…"]],
],
[
[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."
>
<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."
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="flex flex-wrap items-start gap-4">
<Sample label="the folder's right-click menu">
<div className="flex gap-3">
<Rail marked={false} />
<FolderMenu quickCopy />
<FolderMenu />
</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."
>
<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."
>
<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."
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 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>
</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>
</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."
>
<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."
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."
>
<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>
</Reference>
</div>
);
}
+24 -169
View File
@@ -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."
>
<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."
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."
>
<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>
</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."
>
<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."
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 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."
>
<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."
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."
>
<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>
</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",
+5 -4
View File
@@ -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"
}
}
-1
View File
@@ -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",
+5 -4
View File
@@ -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"
}
}
-1
View File
@@ -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",
+33 -3
View File
@@ -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")]);
+32 -5
View File
@@ -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);
}