feat(files): share a folder by link — its page lists what is in it, plays a video above the list
A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id, exactly one set; a folder's links go with it). The page shows what is in the folder when it is opened, newest first: a video or a song plays in the same player a file link has, above the list, each keeping its own resume point; a document only downloads, never opens as a page. A file moved out stops opening through the link at its next request. Password, expiry and stream-only work as on a file link; the unfurl names the folder and counts its files, never their names. Source folders and the Hub cannot be shared. The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail marks a folder a live link opens. Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once, and the new-link form goes back to downloadable after making a link.
This commit is contained in:
@@ -6,6 +6,7 @@ import { BatchC } from "./batchC";
|
||||
import { BatchD } from "./batchD";
|
||||
import { FilesRound } from "./filesRound";
|
||||
import { TokensRound } from "./tokensRound";
|
||||
import { LinksRound } from "./linksRound";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { Slider } from "../components/ui/NativeControls";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
@@ -105,6 +106,14 @@ export default function DesignLab() {
|
||||
Resize the window to tablet size to see them there too.
|
||||
</p>
|
||||
</header>
|
||||
<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).
|
||||
</p>
|
||||
</header>
|
||||
<LinksRound />
|
||||
<header id="open-tokens" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · API tokens, a machine's key to Files</h2>
|
||||
<p className="text-sm text-muted">
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Link2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Decision, Sample, Variant } 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.
|
||||
|
||||
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
||||
|
||||
type File = { name: string; size: string; length?: string; doc?: boolean; hue: string };
|
||||
const FILES: File[] = [
|
||||
{ name: "Balaton 2026 — a móló", size: "48,1 MB", length: "2:14", hue: "from-accent/45" },
|
||||
{ name: "Szülinapi torta, gyertyafújás", size: "19,9 MB", length: "1:11", hue: "from-rating/45" },
|
||||
{ name: "Útvonal és szállás.pdf", size: "640 KB", doc: true, hue: "from-fg/20" },
|
||||
{ name: "Esti tábortűz", size: "7,3 MB", length: "0:38", hue: "from-success/40" },
|
||||
];
|
||||
|
||||
function Thumb({ file, className }: { file: File; className: string }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative shrink-0 overflow-hidden rounded-lg bg-linear-to-br to-card",
|
||||
file.hue,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{file.doc && <FileText className="absolute inset-0 m-auto size-5 text-muted" />}
|
||||
{file.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{file.length}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── L1: making a folder link ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function RailEntry({
|
||||
label,
|
||||
count,
|
||||
shared,
|
||||
active,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
shared?: boolean;
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{shared && <Link2 className="size-3.5 shrink-0 text-accent" aria-label="Linkkel megosztva" />}
|
||||
<span className="text-xs tabular-nums">{count}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ marked }: { marked: boolean }) {
|
||||
return (
|
||||
<aside className="flex w-52 shrink-0 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>
|
||||
<RailEntry label="Cimboráknak" count={4} shared={marked} active />
|
||||
<RailEntry label="Receptek" count={1} />
|
||||
<RailEntry label="Munka" count={7} shared={marked} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderMenu({ quickCopy }: { quickCopy: boolean }) {
|
||||
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"],
|
||||
],
|
||||
[[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], i) => (
|
||||
<span
|
||||
key={label}
|
||||
className={clsx(row, danger && "text-danger", gi === 0 && i === 0 && "bg-fg/8")}
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", danger ? "text-danger" : "text-muted")} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ views, pw }: { views: number; pw?: boolean }) {
|
||||
return (
|
||||
<div className={clsx(SURFACE.card, "flex flex-col gap-2 p-2.5")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="size-4 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate text-xs text-muted">
|
||||
https://siftlode.example/watch/Qk7vN2x…
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<Copy className="size-4" />
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<Lock className={clsx("size-4", pw && "text-accent")} />
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<Trash2 className="size-4 text-danger" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Eye className="size-3" /> {views} megtekintés
|
||||
{pw && <span className="rounded-sm bg-surface px-1.5 py-0.5">jelszóval</span>}
|
||||
<span className="rounded-sm bg-surface px-1.5 py-0.5">letölthető</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderShareDialog() {
|
||||
return (
|
||||
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col gap-3 p-4")}>
|
||||
<div className="flex items-center">
|
||||
<h4 className="flex-1 font-semibold">Mappa megosztása · Cimboráknak</h4>
|
||||
<X className="size-4 text-muted" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Link2 className="size-4 text-muted" /> Megosztás linkkel
|
||||
</div>
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
A link birtokában bárki megnézheti a mappát — fiók nélkül is. Mindig a mappa aktuális
|
||||
tartalmát látja: az ide később tett fájlokat is, a kivetteket már nem.
|
||||
</p>
|
||||
<LinkRow views={3} />
|
||||
<span className={clsx(buttonClass({ variant: "outline", size: "sm" }), "self-start")}>
|
||||
<Link2 className="size-4" /> Link létrehozása
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── L2: the page the link opens ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Brand() {
|
||||
return <span className="text-xs text-muted">Megosztva a Siftlode-dal</span>;
|
||||
}
|
||||
|
||||
function PageFrame({ phone, children }: { phone?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-3 rounded-xl bg-bg p-3 ring-1 ring-border",
|
||||
phone ? "w-[23.5rem] max-w-full" : "w-full",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg font-bold">Cimboráknak</h4>
|
||||
<span className="text-xs text-muted">4 fájl · 76 MB</span>
|
||||
</div>
|
||||
<Brand />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Player({ file }: { file: File }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative aspect-video w-full overflow-hidden rounded-xl bg-linear-to-br to-card",
|
||||
file.hue,
|
||||
)}
|
||||
>
|
||||
<span className="absolute inset-x-3 bottom-2 flex items-center gap-2 text-xs text-fg/80">
|
||||
<Play className="size-3.5" /> 0:41 / {file.length}
|
||||
<span className="h-1 flex-1 rounded-full bg-fg/20">
|
||||
<span className="block h-full w-1/3 rounded-full bg-accent" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 truncate text-sm font-semibold">{file.name}</span>
|
||||
<span className={buttonClass({ variant: "outline", size: "xs" })}>
|
||||
<Download className="size-3.5" /> Letöltés
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) {
|
||||
return (
|
||||
<PageFrame phone={phone}>
|
||||
<Header />
|
||||
{playing && <Player file={FILES[1]!} />}
|
||||
<div className="flex flex-col">
|
||||
{FILES.map((f, i) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded-lg px-1.5 py-1.5",
|
||||
playing && i === 1 && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
<Thumb file={f} className="h-10 w-16" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{f.name}</div>
|
||||
<div className="text-xs text-muted">{f.size}</div>
|
||||
</div>
|
||||
{!f.doc && (
|
||||
<span className={ICON}>
|
||||
<Play className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className={ICON}>
|
||||
<Download className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
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."
|
||||
>
|
||||
<Sample label="the folder's right-click menu">
|
||||
<div className="flex gap-3">
|
||||
<Rail marked={false} />
|
||||
<FolderMenu quickCopy />
|
||||
</div>
|
||||
</Sample>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Share with link… only"
|
||||
why="One entry: every folder link goes through the dialog. One more click for the common case, nothing gained but a shorter menu."
|
||||
>
|
||||
<Sample label="the folder's right-click menu">
|
||||
<FolderMenu quickCopy={false} />
|
||||
</Sample>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="A/B"
|
||||
name="…the dialog either opens"
|
||||
recommended
|
||||
why="It says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
|
||||
>
|
||||
<FolderShareDialog />
|
||||
</Variant>
|
||||
</div>
|
||||
</Decision>
|
||||
|
||||
<Decision
|
||||
id="open-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."
|
||||
>
|
||||
<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>
|
||||
|
||||
<Decision
|
||||
id="open-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."
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user