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.
285 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|