design(files): the one-library round in the lab — aligned list columns, the tray, the done notice
The Files page replaces the Downloads page: list rows with one column per value kind and a right-click menu, an app-wide download tray, a done notice that copies the link. Picked variants are logged in DESIGN.md.
This commit is contained in:
@@ -4,6 +4,7 @@ import { BatchA } from "./batchA";
|
||||
import { BatchB } from "./batchB";
|
||||
import { BatchC } from "./batchC";
|
||||
import { BatchD } from "./batchD";
|
||||
import { FilesRound } from "./filesRound";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { Slider } from "../components/ui/NativeControls";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
@@ -74,6 +75,9 @@ export default function DesignLab() {
|
||||
/>
|
||||
</label>
|
||||
<nav className="flex flex-wrap gap-1 text-xs">
|
||||
<a href="#open-files" className="rounded-md px-1.5 py-0.5 text-accent state-layer">
|
||||
Files
|
||||
</a>
|
||||
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||
A ✓
|
||||
</a>
|
||||
@@ -97,6 +101,13 @@ export default function DesignLab() {
|
||||
Resize the window to tablet size to see them there too.
|
||||
</p>
|
||||
</header>
|
||||
<header id="open-files" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Open · Files, the one library</h2>
|
||||
<p className="text-sm text-muted">
|
||||
The page that replaces Downloads. The recommended variant has the accent edge.
|
||||
</p>
|
||||
</header>
|
||||
<FilesRound />
|
||||
<header id="decided" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
|
||||
<p className="text-sm text-muted">
|
||||
|
||||
@@ -0,0 +1,730 @@
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
Film,
|
||||
Folder,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
HardDrive,
|
||||
LayoutGrid,
|
||||
Link,
|
||||
List,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
Play,
|
||||
RotateCw,
|
||||
Scissors,
|
||||
Search,
|
||||
Server,
|
||||
Share2,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import { Decision, Sample, Variant } 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.
|
||||
|
||||
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
||||
|
||||
type Source = "youtube" | "plex" | "site" | "upload" | "hub";
|
||||
const SOURCE_ICON: Record<Source, typeof Film | typeof YouTubeIcon> = {
|
||||
youtube: YouTubeIcon,
|
||||
plex: Film,
|
||||
site: Globe,
|
||||
upload: Upload,
|
||||
hub: Server,
|
||||
};
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
source: Source;
|
||||
sourceLabel: string;
|
||||
by?: string;
|
||||
kind: "video" | "text" | "pdf";
|
||||
size: string;
|
||||
length?: string;
|
||||
when: string;
|
||||
state: "phone" | "converting" | "file";
|
||||
retention?: { days: number } | "kept";
|
||||
clip?: boolean;
|
||||
hue: string;
|
||||
};
|
||||
|
||||
const ITEMS: Item[] = [
|
||||
{
|
||||
name: "Westworld — S1·E6 — The Adversary",
|
||||
source: "plex",
|
||||
sourceLabel: "Plex",
|
||||
by: "Westworld",
|
||||
kind: "video",
|
||||
size: "12,2 MB",
|
||||
length: "1:03",
|
||||
when: "13 órája",
|
||||
state: "phone",
|
||||
retention: { days: 29 },
|
||||
clip: true,
|
||||
hue: "from-rating/45",
|
||||
},
|
||||
{
|
||||
name: "Első benyomások | Rainbow Six Mobile (iPhone 13)",
|
||||
source: "youtube",
|
||||
sourceLabel: "YouTube",
|
||||
by: "Yeahunter",
|
||||
kind: "video",
|
||||
size: "2,0 MB",
|
||||
length: "0:30",
|
||||
when: "13 órája",
|
||||
state: "phone",
|
||||
retention: { days: 29 },
|
||||
clip: true,
|
||||
hue: "from-accent/45",
|
||||
},
|
||||
{
|
||||
name: "Szméagol szompolyog",
|
||||
source: "youtube",
|
||||
sourceLabel: "YouTube",
|
||||
by: "R Máté",
|
||||
kind: "video",
|
||||
size: "4,8 MB",
|
||||
length: "1:56",
|
||||
when: "2 napja",
|
||||
state: "phone",
|
||||
retention: { days: 4 },
|
||||
hue: "from-success/40",
|
||||
},
|
||||
{
|
||||
name: "Ez a nap is eljött",
|
||||
source: "site",
|
||||
sourceLabel: "tiktok.com",
|
||||
by: "@gasparimre",
|
||||
kind: "video",
|
||||
size: "38,5 MB",
|
||||
length: "0:58",
|
||||
when: "most",
|
||||
state: "converting",
|
||||
retention: { days: 30 },
|
||||
hue: "from-danger/35",
|
||||
},
|
||||
{
|
||||
name: "jelentes.md",
|
||||
source: "hub",
|
||||
sourceLabel: "Hub",
|
||||
kind: "text",
|
||||
size: "4 KB",
|
||||
when: "1 órája",
|
||||
state: "file",
|
||||
hue: "from-fg/15",
|
||||
},
|
||||
{
|
||||
name: "szerzodes.pdf",
|
||||
source: "upload",
|
||||
sourceLabel: "Feltöltés",
|
||||
kind: "pdf",
|
||||
size: "1,1 MB",
|
||||
when: "tegnap",
|
||||
state: "file",
|
||||
retention: "kept",
|
||||
hue: "from-warn/35",
|
||||
},
|
||||
];
|
||||
|
||||
function SourceBadge({ item }: { item: Item }) {
|
||||
const Icon = SOURCE_ICON[item.source];
|
||||
return (
|
||||
<Badge>
|
||||
<Icon />
|
||||
{item.sourceLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
const Glyph = item.kind === "video" ? null : FileText;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative shrink-0 overflow-hidden rounded-lg bg-linear-to-br to-card",
|
||||
item.hue,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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 }) {
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
{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={ICON} title="Továbbiak">
|
||||
<MoreHorizontal />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The page frame: sidebar + toolbar, shared by both layouts ────────────────────────────────────
|
||||
|
||||
function SideEntry({
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
active = false,
|
||||
}: {
|
||||
icon: typeof Film | typeof YouTubeIcon;
|
||||
label: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-8 items-center gap-2 rounded-lg px-2 text-sm",
|
||||
active ? "bg-accent/12 font-medium text-accent" : "text-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{count !== undefined && <span className="text-xs tabular-nums">{count}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="flex w-52 shrink-0 flex-col gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SideEntry icon={HardDrive} label="Minden fájl" count={26} active />
|
||||
<SideEntry icon={Users} label="Megosztva velem" count={3} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="px-2 text-2xs uppercase tracking-wider text-muted">Források</span>
|
||||
<SideEntry icon={YouTubeIcon} label="YouTube" count={12} />
|
||||
<SideEntry icon={Film} label="Plex klipek" count={4} />
|
||||
<SideEntry icon={Globe} label="tiktok.com" count={1} />
|
||||
<SideEntry icon={Upload} label="Feltöltések" count={2} />
|
||||
<SideEntry icon={Server} label="Hub" count={3} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center px-2 text-2xs uppercase tracking-wider text-muted">
|
||||
<span className="flex-1">Saját mappák</span>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</span>
|
||||
<SideEntry icon={Folder} label="Cimboráknak" count={3} />
|
||||
<SideEntry icon={Folder} label="Receptek" count={1} />
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-1 px-2 text-xs text-muted">
|
||||
<span className="tabular-nums">228 MB / 5 GB</span>
|
||||
<span className="h-1 overflow-hidden rounded-full bg-fg/10">
|
||||
<span className="block h-full w-[5%] rounded-full bg-accent" />
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ grid }: { grid: boolean }) {
|
||||
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")}>
|
||||
<Download className="size-4 text-muted" />
|
||||
<span className="flex-1 truncate text-muted">
|
||||
Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)
|
||||
</span>
|
||||
</span>
|
||||
<span className={buttonClass({ variant: "primary", size: "sm" })}>Letöltés</span>
|
||||
<span className="mx-1 h-5 w-px bg-border" />
|
||||
<span
|
||||
className={clsx(fieldClass({ size: "sm", pill: true }), "flex w-44 items-center gap-2")}
|
||||
>
|
||||
<Search className="size-4 text-muted" />
|
||||
<span className="text-muted">Keresés</span>
|
||||
</span>
|
||||
<span className={buttonClass({ variant: "outline", size: "sm" })}>
|
||||
<Upload className="size-4" />
|
||||
Feltöltés
|
||||
</span>
|
||||
<span className="flex">
|
||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={!grid}>
|
||||
<List />
|
||||
</span>
|
||||
<span className={iconButtonClass({ variant: "ghost", size: "sm" })} aria-pressed={grid}>
|
||||
<LayoutGrid />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Frame({ grid, children }: { grid: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto rounded-xl border border-border/60 p-3">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h4 className="text-lg font-bold">Minden fájl</h4>
|
||||
<span className="text-xs text-muted">26 elem</span>
|
||||
<span className="ml-auto flex items-center gap-1 text-xs text-muted">
|
||||
Legújabb elöl <ChevronDown className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
<Toolbar grid={grid} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── F1: the list and the grid ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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").
|
||||
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";
|
||||
|
||||
function ListHead() {
|
||||
const th = "text-2xs uppercase tracking-wider text-muted";
|
||||
return (
|
||||
<div className={clsx(ROW, "py-1")}>
|
||||
<span />
|
||||
<span className={th}>Név</span>
|
||||
<span className={th}>Forrás</span>
|
||||
<span className={th}>Telefon</span>
|
||||
<span className={th}>Megőrzés</span>
|
||||
<span className={clsx(th, "text-right")}>Méret</span>
|
||||
<span className={clsx(th, "text-right")}>Hozzáadva</span>
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCell({ item }: { item: Item }) {
|
||||
if (item.state === "phone")
|
||||
return (
|
||||
<Badge title="Telefonon is lejátszható">
|
||||
<Smartphone />
|
||||
<span className="text-success">✓</span>
|
||||
</Badge>
|
||||
);
|
||||
if (item.state === "converting")
|
||||
return (
|
||||
<Badge tone="accent">
|
||||
<Loader2 className="animate-spin" />
|
||||
Alakítás…
|
||||
</Badge>
|
||||
);
|
||||
return <span className="text-xs text-muted">—</span>;
|
||||
}
|
||||
|
||||
function KeepCell({ item }: { item: Item }) {
|
||||
if (item.retention === "kept")
|
||||
return (
|
||||
<Badge>
|
||||
<Pin />
|
||||
Megtartva
|
||||
</Badge>
|
||||
);
|
||||
if (!item.retention) return <span className="text-xs text-muted">—</span>;
|
||||
return item.retention.days <= 7 ? (
|
||||
<Badge tone="warn">
|
||||
<Clock />
|
||||
{item.retention.days} nap
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs tabular-nums text-muted">{item.retention.days} nap</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ListRows() {
|
||||
return (
|
||||
<div className={LIST_COLUMNS}>
|
||||
<ListHead />
|
||||
{ITEMS.map((item, i) => (
|
||||
<div key={item.name} className={clsx(ROW, i === 3 && "bg-accent/8 ring-1 ring-accent/40")}>
|
||||
<Thumb item={item} className="h-10 w-16" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{item.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
||||
<span className="truncate">{item.by ?? "—"}</span>
|
||||
{item.clip && (
|
||||
<Badge>
|
||||
<Scissors />
|
||||
klip
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
<SourceBadge item={item} />
|
||||
</span>
|
||||
<span>
|
||||
<PhoneCell item={item} />
|
||||
</span>
|
||||
<span>
|
||||
<KeepCell item={item} />
|
||||
</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} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The right-click menu of a row: every action of the item, the row icons' ones first.
|
||||
function RowMenu() {
|
||||
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
|
||||
const groups: [typeof Film, string, boolean?][][] = [
|
||||
[
|
||||
[Play, "Lejátszás"],
|
||||
[Link, "Link másolása"],
|
||||
[Share2, "Megosztás…"],
|
||||
[Download, "Letöltés"],
|
||||
],
|
||||
[
|
||||
[Scissors, "Klip készítése"],
|
||||
[Pencil, "Átnevezés"],
|
||||
[FolderInput, "Áthelyezés…"],
|
||||
[Pin, "Megtartom"],
|
||||
],
|
||||
[[Trash2, "Törlés", true]],
|
||||
];
|
||||
return (
|
||||
<div className={clsx(SURFACE.menu, "w-56 shadow-float")}>
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
{gi > 0 && <div className="my-1 h-px bg-border" />}
|
||||
{g.map(([Icon, label, danger]) => (
|
||||
<span
|
||||
key={label}
|
||||
className={clsx(
|
||||
row,
|
||||
danger ? "text-danger" : "",
|
||||
label === "Link másolása" && "bg-fg/8",
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", danger ? "text-danger" : "text-muted")} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-3 py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{title}</span>
|
||||
<span className="text-xs text-muted">{site}</span>
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Progress({ pct }: { 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>
|
||||
);
|
||||
}
|
||||
|
||||
function TrayPanel() {
|
||||
return (
|
||||
<div className={clsx(SURFACE.popover, "w-80 max-w-full overflow-hidden shadow-float")}>
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<Loader2 className="size-4 animate-spin text-accent" />
|
||||
<span className="flex-1 text-sm font-semibold">Letöltések · 2 fut, 1 hibás</span>
|
||||
<span className={ICON}>
|
||||
<ChevronDown />
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<X />
|
||||
</span>
|
||||
</div>
|
||||
<TrayRow title="Ez a nap is eljött" site="tiktok.com">
|
||||
<Progress pct={64} />
|
||||
</TrayRow>
|
||||
<TrayRow title="Rainbow Six Mobile – teljes meccs" site="YouTube">
|
||||
<span className="text-xs text-muted">Sorban áll…</span>
|
||||
</TrayRow>
|
||||
<TrayRow title="chatgptmagyarul/posts/2564…" site="facebook.com">
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<AlertTriangle className="size-3.5 text-danger" />
|
||||
<span className="flex-1 text-danger">Ebben a posztban nincs videó</span>
|
||||
<span className={buttonClass({ variant: "link", size: "none" })}>
|
||||
<RotateCw className="size-3" />
|
||||
Újra
|
||||
</span>
|
||||
</span>
|
||||
</TrayRow>
|
||||
<TrayRow title="Szméagol szompolyog" site="YouTube">
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<CheckCircle2 className="size-3.5 text-success" />
|
||||
<span className="flex-1 text-success">Kész</span>
|
||||
<span className={buttonClass({ variant: "link", size: "none" })}>Megnyitás</span>
|
||||
</span>
|
||||
</TrayRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrayPill() {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
SURFACE.floating,
|
||||
"inline-flex items-center gap-2 self-start px-3 py-1.5 text-sm",
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin text-accent" />2 letöltés fut
|
||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-fg/10">
|
||||
<span className="block h-full w-2/3 rounded-full bg-accent" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
<div className={clsx(SURFACE.popover, "toast-card flex w-80 max-w-full items-start gap-3 p-3")}>
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-success" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="text-sm font-semibold">Letöltve: Szméagol szompolyog</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<Smartphone className="size-3.5" />
|
||||
Telefonon is lejátszható <span className="text-success">✓</span>
|
||||
</span>
|
||||
<span className="mt-1 flex gap-2">
|
||||
<span className={buttonClass({ variant: "primary", size: "sm" })}>
|
||||
<Copy className="size-4" />
|
||||
Link másolása
|
||||
</span>
|
||||
<span className={buttonClass({ variant: "outline", size: "sm" })}>Megnyitás</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className={ICON}>
|
||||
<X />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilesRound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Decision
|
||||
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."
|
||||
>
|
||||
<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>
|
||||
|
||||
<Decision
|
||||
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."
|
||||
>
|
||||
<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>
|
||||
|
||||
<Decision
|
||||
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."
|
||||
>
|
||||
<DoneToast />
|
||||
</Variant>
|
||||
</Decision>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
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. Every decision 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 the side-by-side <Decision>/<Variant> pair (as before) for its open questions. Everything
|
||||
// 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.
|
||||
|
||||
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
|
||||
@@ -36,6 +37,76 @@ 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 (
|
||||
|
||||
Reference in New Issue
Block a user