Files
siftlode/frontend/src/design-lab/tokensRound.tsx
T
peter 2efa7d5a75 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.
2026-10-15 04:58:17 +02:00

371 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import clsx from "clsx";
import type { ReactNode } from "react";
import {
AlertTriangle,
Copy,
Folder,
KeyRound,
MoreHorizontal,
Plus,
Server,
SlidersHorizontal,
X,
} from "lucide-react";
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";
// 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 = () => {};
type Right = "list" | "read" | "write" | "delete";
const RIGHT_LABEL: Record<Right, string> = {
list: "Lista",
read: "Olvasás",
write: "Írás",
delete: "Törlés",
};
type Token = {
name: string;
prefix: string;
grants: { folder: string; hub?: boolean; rights: Right[] }[];
used: string;
expires: string;
soon?: boolean;
};
const TOKENS: Token[] = [
{
name: "Hub csere",
prefix: "sfl_7Qm2xK9a",
grants: [{ folder: "Hub", hub: true, rights: ["list", "read", "write", "delete"] }],
used: "12 perce",
expires: "2027. okt. 14.",
},
{
name: "Telefon – receptek",
prefix: "sfl_Bw41LdZr",
grants: [
{ folder: "Receptek", rights: ["list", "read"] },
{ folder: "Cimboráknak", rights: ["list", "read", "write"] },
],
used: "soha",
expires: "6 nap múlva",
soon: true,
},
];
/** The Settings page frame the tab lives in: the tab rail on the left, the tab on the right. */
function SettingsFrame({ children }: { children: ReactNode }) {
return (
<div className={clsx(SURFACE.panel, "flex max-w-3xl overflow-hidden")}>
<div className="hidden w-36 shrink-0 flex-col gap-1 border-r border-border/60 p-2 text-sm sm:flex">
{["Megjelenés", "Értesítések", "Letöltések", "API-tokenek", "Fiók"].map((l) => (
<span
key={l}
className={clsx(
"rounded-lg px-2.5 py-1.5",
l === "API-tokenek" ? "bg-accent/15 text-accent font-medium" : "text-muted",
)}
>
{l}
</span>
))}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 p-4">
<div className="flex items-start gap-3">
<div className="flex-1">
<h3 className="text-sm font-semibold">API-tokenek</h3>
<p className="text-xs leading-relaxed text-muted">
Egy token egy gépnek ad kulcsot a Fájlok könyvtár kijelölt mappáihoz — semmi máshoz.
</p>
</div>
<span className={buttonClass({ variant: "primary", size: "sm" })}>
<Plus className="size-4" />
Új token
</span>
</div>
{children}
</div>
</div>
);
}
function RightsLine({ grants }: { grants: Token["grants"] }) {
return (
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
{grants.map((g) => (
<span key={g.folder} className="flex items-center gap-1.5">
{g.hub ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
<span className="text-fg">{g.folder}</span>
<span>· {g.rights.map((r) => RIGHT_LABEL[r].toLowerCase()).join(", ")}</span>
</span>
))}
</span>
);
}
// ── T1 · the list ─────────────────────────────────────────────────────────────────────────────
function ListRows() {
return (
<div className="flex flex-col divide-y divide-border/60">
{TOKENS.map((tk) => (
<div key={tk.prefix} className="flex items-start gap-3 py-2.5">
<KeyRound className="mt-0.5 size-4 shrink-0 text-muted" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{tk.name}</span>
<code className="text-xs text-muted">{tk.prefix}…</code>
{tk.soon && <Badge tone="warn">Hamarosan lejár</Badge>}
</span>
<span className="text-xs text-muted">
<RightsLine grants={tk.grants} />
</span>
<span className="text-xs text-muted">
Utoljára használva: {tk.used} · Lejár: {tk.expires}
</span>
</div>
<span className={ICON} aria-label="Műveletek">
<MoreHorizontal className="size-4" />
</span>
</div>
))}
</div>
);
}
// ── T2 · the new-token dialog ───────────────────────────────────────────────────────────────────
function Dialog({
title,
children,
foot,
}: {
title: string;
children: ReactNode;
foot: ReactNode;
}) {
return (
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col shadow-float")}>
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-3">
<h4 className="flex-1 text-sm font-semibold">{title}</h4>
<span className={ICON} aria-label="Bezárás">
<X className="size-4" />
</span>
</div>
<div className="flex flex-col gap-3 p-4 text-sm">{children}</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-4 py-3">{foot}</div>
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs text-muted">{label}</span>
{children}
</label>
);
}
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={l === "1 év"} onClick={noop}>
{l}
</Chip>
))}
</span>
</Field>
);
}
const FOLDERS: { name: string; kind: "hub" | "own" | "source"; on: Right[] }[] = [
{ name: "Hub", kind: "hub", on: [] },
{ name: "Receptek", kind: "own", on: ["list", "read"] },
{ name: "Cimboráknak", kind: "own", on: ["list", "read", "write"] },
{ name: "YouTube", kind: "source", on: [] },
];
function RightsMatrix() {
return (
<div className="flex flex-col divide-y divide-border/50 rounded-lg border border-border/60">
{FOLDERS.map((f) => (
<div key={f.name} className="flex flex-wrap items-center gap-2 px-2.5 py-2">
<span className="flex min-w-28 flex-1 items-center gap-1.5">
{f.kind === "hub" ? <Server className="size-3.5" /> : <Folder className="size-3.5" />}
{f.name}
</span>
<span className="flex gap-1">
{(Object.keys(RIGHT_LABEL) as Right[]).map((r) =>
// A source folder fills itself: nothing can be uploaded into it, so no Write.
f.kind === "source" && r === "write" ? null : (
<Chip key={r} pressed={f.on.includes(r)} onClick={noop}>
{RIGHT_LABEL[r]}
</Chip>
),
)}
</span>
</div>
))}
</div>
);
}
function PresetFirst({ custom }: { custom: boolean }) {
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>
</>
}
>
<div className="grid grid-cols-2 gap-2">
{[
{
on: !custom,
icon: Server,
title: "Hub csere",
text: "Egy közös Hub mappa; a token mindent tehet benne.",
},
{
on: custom,
icon: SlidersHorizontal,
title: "Egyedi",
text: "Te választod a mappákat és a jogokat.",
},
].map((c) => (
<span
key={c.title}
className={clsx(
SURFACE.card,
"flex flex-col gap-1 border p-3",
c.on ? "border-accent" : "border-transparent",
)}
>
<span className="flex items-center gap-1.5 font-medium">
<c.icon className="size-4" />
{c.title}
</span>
<span className="text-xs text-muted">{c.text}</span>
</span>
))}
</div>
<Field label="Név">
<span className={clsx(fieldClass({ size: "sm" }), "flex items-center")}>
{custom ? "Telefon – receptek" : "Hub csere"}
</span>
</Field>
{custom && <RightsMatrix />}
<Expiry />
</Dialog>
);
}
// ── T3 · the token, shown once ──────────────────────────────────────────────────────────────────
const RAW = "sfl_7Qm2xK9aVt3pLc8RzN0eWq5uYb1HdJ6gFs4kMx2oTi9a3F7kQe";
function CopyRow({ children, mono = true }: { children: ReactNode; mono?: boolean }) {
return (
<span className="flex items-stretch gap-1.5">
<code
className={clsx(
fieldClass({ size: "sm" }),
"flex min-w-0 flex-1 items-center break-all h-auto py-1.5",
mono && "font-mono text-xs",
)}
>
{children}
</code>
<span className={buttonClass({ variant: "outline", size: "sm" })}>
<Copy className="size-4" />
Másolás
</span>
</span>
);
}
function ShownInDialog() {
return (
<Dialog
title="A token elkészült"
foot={<span className={buttonClass({ variant: "primary", size: "sm" })}>Kész</span>}
>
<span className="flex items-start gap-1.5 text-xs text-warn">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
Most látod utoljára: mi csak a lenyomatát őrizzük. Ha elveszik, csinálj újat.
</span>
<Field label="Token">
<CopyRow>{RAW}</CopyRow>
</Field>
<Field label="Kipróbálás (curl)">
<CopyRow>
curl -H &quot;Authorization: Bearer {RAW.slice(0, 12)}…&quot;
https://siftlode.example/api/files/folders
</CopyRow>
</Field>
</Dialog>
);
}
// ── The round ───────────────────────────────────────────────────────────────────────────────────
export function TokensRound() {
return (
<div className="flex flex-col gap-4">
<Reference
id="tokens-t1"
code="T1"
title="The token list (Settings → API tokens)"
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>
</Reference>
<Reference
id="tokens-t2"
code="T2"
title="Making a token"
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} />
</Sample>
<Sample label="Egyedi">
<PresetFirst custom />
</Sample>
</div>
</Reference>
<Reference
id="tokens-t3"
code="T3"
title="The token, shown once"
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>
</Reference>
</div>
);
}