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:
2026-10-15 02:23:24 +02:00
parent 90567718b2
commit 86712b6b0e
33 changed files with 1880 additions and 321 deletions
+13 -1
View File
@@ -3,7 +3,7 @@ import { Meter } from "./ui/Meter";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { FolderOpen, FolderPlus, HardDrive, Users } from "lucide-react";
import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
import { api, type FileFolder } from "../lib/api";
import { compareText } from "../lib/collate";
import { formatBytes } from "../lib/format";
@@ -36,16 +36,20 @@ function Entry({
view,
trigger,
folderId,
shared = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
/** A live public link opens the folder: what is moved in is shown to whoever holds the link. */
shared?: boolean;
view: FilesView;
/** The folder's menu triggers (right click, long press, Shift+F10). */
trigger?: Record<string, unknown>;
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
folderId?: number;
}) {
const { t } = useTranslation();
const { view: current } = useFiles();
const { setView } = useFilesActions();
const active = sameView(view, current);
@@ -64,6 +68,12 @@ function Entry({
>
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
{shared && (
<span title={t("files.folder.shared")} className="shrink-0">
<Link2 aria-hidden className="size-3.5 text-accent" />
<span className="sr-only">{t("files.folder.shared")}</span>
</span>
)}
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
</Pressable>
);
@@ -176,6 +186,7 @@ export default function FilesRail({
icon={folderIcon(f)}
label={folderLabel(f, t)}
count={f.count}
shared={f.has_link}
view={{ kind: "folder", id: f.id }}
trigger={menu.triggerProps(f)}
folderId={f.id}
@@ -267,6 +278,7 @@ export default function FilesRail({
ariaLabel={t("files.folder.menu")}
items={menu.target ? folderActions.items(menu.target) : []}
/>
{folderActions.dialog}
</FilterRail>
);
}