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:
@@ -774,3 +774,7 @@ of a full-width panel and nothing on the right.
|
|||||||
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
||||||
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
||||||
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
||||||
|
| 2026-10-11 | Files page (one library) | 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 | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
|
||||||
|
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
|
||||||
|
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
|
||||||
|
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BatchA } from "./batchA";
|
|||||||
import { BatchB } from "./batchB";
|
import { BatchB } from "./batchB";
|
||||||
import { BatchC } from "./batchC";
|
import { BatchC } from "./batchC";
|
||||||
import { BatchD } from "./batchD";
|
import { BatchD } from "./batchD";
|
||||||
|
import { FilesRound } from "./filesRound";
|
||||||
import { Chip } from "../components/ui/Chip";
|
import { Chip } from "../components/ui/Chip";
|
||||||
import { Slider } from "../components/ui/NativeControls";
|
import { Slider } from "../components/ui/NativeControls";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
@@ -74,6 +75,9 @@ export default function DesignLab() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<nav className="flex flex-wrap gap-1 text-xs">
|
<nav className="flex flex-wrap gap-1 text-xs">
|
||||||
|
<a href="#open-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 href="#decided" className="rounded-md px-1.5 py-0.5 text-success state-layer">
|
||||||
A ✓
|
A ✓
|
||||||
</a>
|
</a>
|
||||||
@@ -97,6 +101,13 @@ export default function DesignLab() {
|
|||||||
Resize the window to tablet size to see them there too.
|
Resize the window to tablet size to see them there too.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<header id="decided" className="mt-6 scroll-mt-20">
|
||||||
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
|
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
|
||||||
<p className="text-sm text-muted">
|
<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 { useSyncExternalStore, type ReactNode } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { Badge } from "../components/ui/Badge";
|
||||||
import { SURFACE } from "../components/ui/surfaces";
|
import { SURFACE } from "../components/ui/surfaces";
|
||||||
|
|
||||||
// Building blocks of the design lab. Every decision is taken, so the lab is the reference half
|
// Building blocks of the design lab. A taken decision is a <Reference>: the chosen rendering with the
|
||||||
// only: each <Reference> is the chosen rendering with the rule it became. A new design round brings
|
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>s side
|
||||||
// back the side-by-side <Decision>/<Variant> pair (as before) for its open questions. Everything
|
// by side, until the owner picks. Everything
|
||||||
// is static markup on purpose: a rendering that only exists here must not need app state.
|
// is static markup on purpose: a rendering that only exists here must not need app state.
|
||||||
|
|
||||||
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
|
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
|
||||||
@@ -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. */
|
/** A small caption above a sample inside a variant. */
|
||||||
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user