Files
siftlode/frontend/src/modules/files/FilesRail.tsx
T
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00

285 lines
10 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Meter } from "../../components/ui/Meter";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
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";
import { type PanelLayout } from "../../lib/panelLayout";
import { qk } from "../../lib/queryKeys";
import { useLiveQuery } from "../../lib/useLiveQuery";
import FilterRail from "../../components/ui/FilterRail";
import { folderIcon, folderLabel } from "./fileFolders";
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
import { RenameField } from "../../components/ui/RenameField";
import { useFolderActions } from "./useFolderActions";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { Pressable } from "../../components/ui/Pressable";
import { type PanelItem } from "../../components/ui/PanelGroups";
import { useContextMenu } from "../../components/ui/useContextMenu";
// The Files module's left rail: the library's places — every file, the ones shared with me, the
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it
// picks lives in FilesProvider, which the Files page reads. A folder's right click (or long press, or
// Shift+F10) opens its menu (`useFolderActions`); the same menu is the "…" beside an open folder's
// name on the page, so nothing here is reachable only by a gesture.
const folderKey = (f: FileFolder) => f.id;
function Entry({
icon: Icon,
label,
count,
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);
return (
<Pressable
{...trigger}
data-folder-id={folderId}
selectable
layer={!active}
aria-current={active ? "page" : undefined}
onClick={() => setView(view)}
className={clsx(
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
active ? "bg-tint-current font-medium text-accent" : "text-fg",
)}
>
<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>
);
}
function StorageMeter() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// Live while something is in flight (a download's bytes land when the worker finishes it), at rest
// otherwise — the old Download Center's meter, moved to where the files are.
const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, {
intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
});
const u = usage.data;
// A failed read says so where the figures would be, rather than the meter quietly not being there.
if (!u)
return usage.isError ? (
<p className="mt-3 px-2 text-xs text-muted" data-testid="storage-meter">
{t("files.usage.failed")}
</p>
) : null;
return (
<div className="mt-3 flex flex-col gap-1 px-2 text-xs text-muted" data-testid="storage-meter">
<span className="flex justify-between gap-2">
<span>{t("files.usage.title")}</span>
<span className="tabular-nums">
{formatBytes(u.footprint_bytes, lang)}
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`}
</span>
</span>
{!u.unlimited && (
<Meter value={u.footprint_bytes} max={u.max_bytes} label={t("files.usage.title")} />
)}
</div>
);
}
export default function FilesRail({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation();
const { view } = useFiles();
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
const folders = foldersQ.data ?? [];
const total = folders.reduce((n, f) => n + f.count, 0);
// A source folder its files have all left stays on the server; here it shows only while it holds
// something, or while it is the one open (so the page never points at a folder the rail hides).
const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id });
const byName = (a: FileFolder, b: FileFolder) =>
compareText(folderLabel(a, t), folderLabel(b, t), i18n.language);
const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName);
const own = folders.filter((f) => f.kind === "own").sort(byName);
// The Hub folder closes the sources, shown even while empty: it exists only once a hub token made
// it, and an empty one is where the hub's files will turn up.
const hub = folders.filter((f) => f.kind === "hub");
const [renamingId, setRenamingId] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
// A name field left by Enter or Escape hands the keyboard back to what stands there now: the
// folder's entry, or the New-folder command (RenameField).
const railRef = useRef<HTMLDivElement>(null);
const refocusFolder = useRef<number | null>(null);
useEffect(() => {
const id = refocusFolder.current;
if (renamingId !== null || id === null) return;
refocusFolder.current = null;
railRef.current?.querySelector<HTMLElement>(`[data-folder-id="${id}"]`)?.focus();
}, [renamingId]);
const newFolder = useRef<HTMLButtonElement>(null);
const refocusNew = useRef(false);
useEffect(() => {
if (creating || !refocusNew.current) return;
refocusNew.current = false;
newFolder.current?.focus();
}, [creating]);
const folderActions = useFolderActions({ onRename: (f) => setRenamingId(f.id) });
const menu = useContextMenu<FileFolder>({ keyOf: folderKey });
const failed = foldersQ.isError && !foldersQ.data && (
<Pressable
onClick={() => foldersQ.refetch()}
className="px-2 py-1 text-xs text-danger hover:underline"
>
{t("common.loadError")} — {t("common.retry")}
</Pressable>
);
const entry = (f: FileFolder) =>
renamingId === f.id ? (
<RenameField
key={f.id}
initial={f.name ?? ""}
label={t("files.folder.rename")}
onDone={(name, byKey) => {
if (byKey) refocusFolder.current = f.id;
setRenamingId(null);
if (name !== null) folderActions.rename(f, name);
}}
/>
) : (
<Entry
key={f.id}
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}
/>
);
const items: PanelItem[] = [
{
id: "library",
title: t("files.rail.library"),
body: (
<div className="flex flex-col gap-0.5">
<Entry
icon={HardDrive}
label={t("files.rail.all")}
count={foldersQ.data ? total : undefined}
view={{ kind: "all" }}
/>
<Entry icon={Users} label={t("files.rail.shared")} view={{ kind: "shared" }} />
</div>
),
},
{
id: "sources",
title: t("files.rail.sources"),
body: (
<div className="flex flex-col gap-0.5">
{failed}
{sources.map(entry)}
{hub.map(entry)}
{/* Only when there is nothing at all: files moved into own folders were still downloaded. */}
{foldersQ.data && sources.length + hub.length === 0 && total === 0 && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noSources")}</span>
)}
</div>
),
},
{
id: "own",
title: t("files.rail.own"),
body: (
<div ref={railRef} className="flex flex-col gap-0.5">
{failed}
{own.map(entry)}
{foldersQ.data && own.length === 0 && !creating && (
<span className="px-2 py-1 text-xs text-muted">{t("files.rail.noOwn")}</span>
)}
{creating ? (
<RenameField
initial=""
label={t("files.folder.newName")}
onDone={(name, byKey) => {
refocusNew.current = byKey;
setCreating(false);
if (name !== null) void folderActions.add(name);
}}
/>
) : (
foldersQ.data && (
<Pressable
ref={newFolder}
onClick={() => setCreating(true)}
className="flex min-h-8 w-full items-center gap-2 rounded-lg px-2 text-sm text-muted hover:text-fg"
>
<FolderPlus className="size-4 shrink-0" />
{t("files.folder.new")}
</Pressable>
)
)}
</div>
),
},
];
return (
<FilterRail
kind="files"
title={t("files.navLabel")}
icon={<FolderOpen className="w-4 h-4" />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
>
<StorageMeter />
<ContextMenu
menu={menu}
ariaLabel={t("files.folder.menu")}
items={menu.target ? folderActions.items(menu.target) : []}
/>
{folderActions.dialog}
</FilterRail>
);
}