feat(files): folders managed in place, a grid per folder, drop to upload, open on one file
- Own folders are made, renamed and deleted from the rail (right click, long press, Shift+F10) or the "…" beside an open folder's name; any folder's retention is a submenu (7/30/90 days, until deleted). Deleting confirms where its files go. - List or grid per folder, sharing one sort; the grid is the one media card. - A file dropped anywhere on the page opens Upload with it, bound for the folder on screen. - A link (?item=) or the upload notice opens Files on the file: its page turned to, the keyboard on it, a ring round it. - An in-place name field left by Enter or Escape hands the focus back to what replaced it (the row, the entry, the "…") instead of dropping it on <body>. - The table's saved page size is per account.
This commit is contained in:
+313
-138
@@ -1,36 +1,40 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
Download,
|
||||
Link,
|
||||
Pin,
|
||||
Scissors,
|
||||
Share2,
|
||||
Smartphone,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { Download, LayoutGrid, Link, List, Scissors, Share2, UploadCloud } from "lucide-react";
|
||||
import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api";
|
||||
import { type SortState, sortRows } from "../lib/columnSort";
|
||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||
import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
|
||||
import { focusNavItem } from "../lib/listNav";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { LS } from "../lib/storage";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { JobThumb } from "./downloadRow";
|
||||
import { MetaEditModal } from "./downloadModals";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders";
|
||||
import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { folderLabel } from "./fileFolders";
|
||||
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||
import { useFileActions } from "./useFileActions";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
import { RenameField } from "./RenameField";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Button } from "./ui/Button";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import DataTable, { type Column } from "./ui/DataTable";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import Pager from "./ui/Pager";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
@@ -44,12 +48,41 @@ const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
|
||||
// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of
|
||||
// value in its own column, an action that does not apply leaving its slot empty, the same menu on a
|
||||
// right click and on the "…" (DESIGN.md "Files page", "Library list columns").
|
||||
// right click and on the "…" (DESIGN.md "Files page", "Library list columns"). The grid is the same
|
||||
// rows' other paint — a toggle remembered per folder, sharing the table's sort.
|
||||
//
|
||||
// A link or a notice can open the page on ONE file (`openFile`, `?item=`): the page turns to it, puts
|
||||
// the keyboard on it and rings it until the view changes. A file dropped anywhere on the page goes
|
||||
// into the Upload dialog, bound for the folder on screen.
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// A file this close to leaving says so in the warning colour.
|
||||
const SOON_DAYS = 7;
|
||||
const fileKey = (f: LibraryFile) => f.id;
|
||||
const folderKey = (f: FileFolder) => f.id;
|
||||
|
||||
type FilesLayout = "list" | "grid";
|
||||
const LAYOUTS: readonly FilesLayout[] = ["list", "grid"];
|
||||
const LAYOUT_ICON = { list: List, grid: LayoutGrid } as const;
|
||||
|
||||
/** A stored per-view layout map: the pref round-trips the server, so it is checked, not trusted. */
|
||||
function parseLayouts(raw: unknown): Record<string, FilesLayout> {
|
||||
if (!raw || typeof raw !== "object") return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).filter((e): e is [string, FilesLayout] => LAYOUTS.includes(e[1])),
|
||||
);
|
||||
}
|
||||
|
||||
/** Put the keyboard on a file's row or card (lib/listNav), whichever paint is on screen. */
|
||||
function focusFile(within: HTMLElement | null, id: number): void {
|
||||
const item = within && shownFileElement(within, id)?.closest<HTMLElement>("[data-nav-index]");
|
||||
if (within && item) focusNavItem(within, Number(item.getAttribute("data-nav-index")));
|
||||
}
|
||||
|
||||
/** The element a file is painted as on screen — its table row, the row's narrow card twin, or its
|
||||
* grid card, whichever is shown (DataTable keeps both row shapes mounted). */
|
||||
function shownFileElement(within: HTMLElement, id: number): HTMLElement | null {
|
||||
const all = [...within.querySelectorAll<HTMLElement>(`[data-file-id="${id}"]`)];
|
||||
// Nothing laid out at all (no layout engine, as under test): the first is the only one there is.
|
||||
return all.find((el) => el.getClientRects().length > 0) ?? all[0] ?? null;
|
||||
}
|
||||
|
||||
/** The list a view reads. The search is applied here, on the rows (the list is the whole library,
|
||||
* capped server-side): a request per keystroke would buy nothing the rows do not already hold. */
|
||||
@@ -73,39 +106,6 @@ function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number |
|
||||
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
|
||||
}
|
||||
|
||||
function daysLeft(iso: string): number {
|
||||
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS));
|
||||
}
|
||||
|
||||
function RetentionCell({ file }: { file: LibraryFile }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (file.kept)
|
||||
return (
|
||||
<Badge>
|
||||
<Pin />
|
||||
{t("files.retention.kept")}
|
||||
</Badge>
|
||||
);
|
||||
if (!file.expires_at)
|
||||
return (
|
||||
<span className="text-xs text-muted" title={t("files.retention.never")}>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
const days = daysLeft(file.expires_at);
|
||||
const when = formatDateTime(file.expires_at, i18n.language);
|
||||
return days <= SOON_DAYS ? (
|
||||
<Badge tone="warn" title={t("files.retention.until", { when })}>
|
||||
<Clock />
|
||||
{t("files.retention.days", { count: days })}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs tabular-nums text-muted" title={t("files.retention.until", { when })}>
|
||||
{t("files.retention.days", { count: days })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NameCell({
|
||||
file,
|
||||
renaming,
|
||||
@@ -115,36 +115,14 @@ function NameCell({
|
||||
file: LibraryFile;
|
||||
renaming: boolean;
|
||||
onPlay: (() => void) | null;
|
||||
onRenamed: (name: string | null) => void;
|
||||
onRenamed: (name: string | null, byKey: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState(() => jobTitle(file));
|
||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||
if (renaming)
|
||||
return (
|
||||
<Input
|
||||
size="sm"
|
||||
className="w-full"
|
||||
autoFocus
|
||||
value={draft}
|
||||
aria-label={t("files.actions.rename")}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => onRenamed(draft)}
|
||||
onKeyDown={(e) => {
|
||||
// preventDefault: a real Enter's own activation would otherwise press what focus lands on.
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onRenamed(draft);
|
||||
}
|
||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onRenamed(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<RenameField initial={jobTitle(file)} label={t("files.actions.rename")} onDone={onRenamed} />
|
||||
);
|
||||
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
|
||||
return (
|
||||
@@ -178,8 +156,8 @@ function NameCell({
|
||||
export default function Files() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { search, view } = useFiles();
|
||||
const { setView } = useFilesActions();
|
||||
const { search, view, focus, arrived } = useFiles();
|
||||
const { setView, consumeFocus } = useFilesActions();
|
||||
|
||||
const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 });
|
||||
const folders = foldersQ.data ?? [];
|
||||
@@ -198,8 +176,18 @@ export default function Files() {
|
||||
const q = search.trim();
|
||||
const rows = all.filter((f) => matches(f, q, lang));
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [upload, setUpload] = useState(false);
|
||||
// A rename left by Enter or Escape hands the keyboard back to its row (RenameField).
|
||||
const refocusFile = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = refocusFile.current;
|
||||
if (renamingId !== null || id === null) return;
|
||||
refocusFile.current = null;
|
||||
focusFile(listRef.current, id);
|
||||
}, [renamingId]);
|
||||
// The Upload dialog while open, with the file a drop on the page brought (null: pick one in it).
|
||||
const [upload, setUpload] = useState<{ file: File | null } | null>(null);
|
||||
const [metaFile, setMetaFile] = useState<LibraryFile | null>(null);
|
||||
const [shareFile, setShareFile] = useState<LibraryFile | null>(null);
|
||||
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
|
||||
@@ -220,6 +208,19 @@ export default function Files() {
|
||||
const menuItems = rowMenu.target ? actions.items(rowMenu.target) : [];
|
||||
|
||||
const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined;
|
||||
// The open folder's menu, on the "…" beside its name — the one its rail entry's right click opens.
|
||||
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||
const folderActions = useFolderActions({ onRename: () => setRenamingFolder(true) });
|
||||
const folderMenu = useContextMenu<FileFolder>({ keyOf: folderKey });
|
||||
// Its rename left by a key hands the keyboard back to the "…" it was opened from.
|
||||
const folderMore = useRef<HTMLButtonElement>(null);
|
||||
const refocusFolderMore = useRef(false);
|
||||
useEffect(() => {
|
||||
if (renamingFolder || !refocusFolderMore.current) return;
|
||||
refocusFolderMore.current = false;
|
||||
folderMore.current?.focus();
|
||||
}, [renamingFolder]);
|
||||
|
||||
// A folder view names its folder once the folder list is in (blank until then, never a guess).
|
||||
const title =
|
||||
shown.kind === "shared"
|
||||
@@ -230,8 +231,85 @@ export default function Files() {
|
||||
: ""
|
||||
: t("files.rail.all");
|
||||
const canUpload = shown.kind !== "shared";
|
||||
const uploadTarget = uploadFolder(shown, folders);
|
||||
const uploadTargetName =
|
||||
uploadTarget !== null && open ? folderLabel(open, t) : t("files.source.upload");
|
||||
|
||||
// List or grid, per view (DESIGN.md "Files page": the grid a toggle remembered per folder).
|
||||
const [layouts, setLayouts] = usePref<Record<string, FilesLayout>>(
|
||||
"filesLayouts",
|
||||
{},
|
||||
{
|
||||
localKey: LS.filesLayouts,
|
||||
parse: parseLayouts,
|
||||
},
|
||||
);
|
||||
const viewKey = viewParam(shown);
|
||||
const layout: FilesLayout = layouts[viewKey] ?? "list";
|
||||
const isGrid = layout === "grid";
|
||||
// One sort for both paints: the table's headers and the grid's dropdown move the same state.
|
||||
const [sort, setSort] = usePref<SortState>("filesSort", null, { localKey: LS.filesSort });
|
||||
|
||||
// ── Arrival at one file ─────────────────────────────────────────────────────────────────────────
|
||||
// A pending arrival (`focus`) is looked for once the list is in: found here, it becomes `arrived`
|
||||
// (FilesProvider), which turns the table's or the grid's page to the file (`reveal`), puts the
|
||||
// keyboard on it and rings it while this view is open. Not in this view, it is looked for among
|
||||
// every file; nowhere (gone since the link was made), it leaves nothing to arrive at, and is spent.
|
||||
const here = !!focus && !!filesQ.data?.some((f) => f.id === focus.id);
|
||||
// "Not here" only once the list is fresh: a just-finished upload is not in the cached rows yet.
|
||||
const missing = !!focus && !!filesQ.data && !here && !filesQ.isFetching;
|
||||
const lookEverywhere = missing && shown.kind !== "all";
|
||||
useEffect(() => {
|
||||
if (lookEverywhere) setView(ALL_FILES);
|
||||
else if (here) consumeFocus(viewKey);
|
||||
else if (missing) consumeFocus(null);
|
||||
}, [here, missing, lookEverywhere, viewKey, setView, consumeFocus]);
|
||||
const arrival = arrived && arrived.view === viewKey ? arrived : null;
|
||||
const ringed = arrival?.id ?? null;
|
||||
const reveal = arrival && { key: String(arrival.id), seq: arrival.token };
|
||||
// The keyboard half, once the page that holds the file has painted.
|
||||
const focusArrived = useEffectEvent(() => {
|
||||
if (ringed !== null) focusFile(listRef.current, ringed);
|
||||
});
|
||||
// Once per arrival (its token), not on every list re-read or layout switch.
|
||||
const arrivalToken = arrival?.token;
|
||||
useEffect(() => {
|
||||
if (arrivalToken !== undefined) focusArrived();
|
||||
}, [arrivalToken]);
|
||||
|
||||
// ── A file dropped anywhere on the page ─────────────────────────────────────────────────────────
|
||||
// The page is the target, not a box on it: a short folder leaves most of the screen empty. Window
|
||||
// listeners, since the drag starts outside the app. While the dialog is open its own zone takes the
|
||||
// drop; a drag that carries no files (a link, selected text) is not ours.
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
const dropReady = canUpload && upload === null;
|
||||
const onDrag = useEffectEvent((e: DragEvent) => {
|
||||
if (!dropReady || !e.dataTransfer?.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
if (e.type === "dragenter") dragDepth.current += 1;
|
||||
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (e.type === "drop") {
|
||||
dragDepth.current = 0;
|
||||
const dropped = e.dataTransfer.files;
|
||||
const first = dropped.item(0);
|
||||
if (first) setUpload({ file: first });
|
||||
if (dropped.length > 1) notify({ level: "info", message: t("files.drop.oneAtATime") });
|
||||
}
|
||||
setDragging(dragDepth.current > 0);
|
||||
});
|
||||
useEffect(() => {
|
||||
const on = (e: DragEvent) => onDrag(e);
|
||||
const kinds = ["dragenter", "dragover", "dragleave", "drop"] as const;
|
||||
for (const k of kinds) window.addEventListener(k, on);
|
||||
return () => {
|
||||
for (const k of kinds) window.removeEventListener(k, on);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
|
||||
const playOf = (f: LibraryFile) =>
|
||||
(f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null;
|
||||
const columns: Column<LibraryFile>[] = [
|
||||
{
|
||||
key: "thumb",
|
||||
@@ -254,52 +332,37 @@ export default function Files() {
|
||||
cardPrimary: true,
|
||||
sortable: true,
|
||||
sortValue: (f) => jobTitle(f),
|
||||
render: (f) => {
|
||||
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
|
||||
return (
|
||||
<NameCell
|
||||
key={renamingId === f.id ? "renaming" : "shown"}
|
||||
file={f}
|
||||
renaming={renamingId === f.id}
|
||||
onPlay={a.play ? () => play(f) : null}
|
||||
onRenamed={(name) => {
|
||||
setRenamingId(null);
|
||||
if (name !== null) actions.rename(f, name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
render: (f) => (
|
||||
<NameCell
|
||||
key={renamingId === f.id ? "renaming" : "shown"}
|
||||
file={f}
|
||||
renaming={renamingId === f.id}
|
||||
onPlay={playOf(f)}
|
||||
onRenamed={(name, byKey) => {
|
||||
if (byKey) refocusFile.current = f.id;
|
||||
setRenamingId(null);
|
||||
if (name !== null) actions.rename(f, name);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: t("files.columns.source"),
|
||||
width: "8rem",
|
||||
tabletHidden: true,
|
||||
render: (f) => {
|
||||
const Icon = sourceIcon(f.source_key);
|
||||
return (
|
||||
<Badge>
|
||||
<Icon />
|
||||
{sourceLabel(f.source_key, t)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
render: (f) => <SourceBadge file={f} />,
|
||||
},
|
||||
{
|
||||
key: "phone",
|
||||
header: t("files.columns.phone"),
|
||||
width: "5.5rem",
|
||||
narrowHidden: true,
|
||||
// Every library video and sound is phone-safe by construction (backend downloads/playable.py);
|
||||
// a plain file is not a thing a phone plays.
|
||||
render: (f) =>
|
||||
f.asset?.media_kind === "file" ? (
|
||||
<span className="text-xs text-muted">—</span>
|
||||
) : (
|
||||
<Badge title={t("files.phoneOk")}>
|
||||
<Smartphone />
|
||||
<span className="text-success">✓</span>
|
||||
</Badge>
|
||||
<PhoneBadge file={f} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -307,7 +370,7 @@ export default function Files() {
|
||||
header: t("files.columns.retention"),
|
||||
width: "7.5rem",
|
||||
narrowHidden: true,
|
||||
render: (f) => <RetentionCell file={f} />,
|
||||
render: (f) => <RetentionBadge file={f} />,
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
@@ -396,69 +459,166 @@ export default function Files() {
|
||||
},
|
||||
];
|
||||
|
||||
// The grid sorts and pages here; the table does both itself (from the same sort).
|
||||
const cards = isGrid ? sortRows(rows, columns, sort, lang) : rows;
|
||||
const { paged, pagerProps } = useCardPager(
|
||||
cards,
|
||||
LS.filesCardSize,
|
||||
isGrid,
|
||||
reveal && { seq: reveal.seq, match: (f) => String(f.id) === reveal.key },
|
||||
t("files.perPage"),
|
||||
);
|
||||
|
||||
const emptyText = q
|
||||
? t("files.empty.search", { q })
|
||||
: t(
|
||||
shown.kind === "shared"
|
||||
? "files.empty.shared"
|
||||
: shown.kind === "folder"
|
||||
? "files.empty.folder"
|
||||
: "files.empty.all",
|
||||
);
|
||||
|
||||
const grid =
|
||||
cards.length === 0 ? (
|
||||
<StateMessage>{emptyText}</StateMessage>
|
||||
) : (
|
||||
<>
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||
{paged.map((f, i) => (
|
||||
<FileCard
|
||||
key={f.id}
|
||||
file={f}
|
||||
index={i}
|
||||
onPlay={playOf(f)}
|
||||
menuProps={rowMenu.triggerProps(f)}
|
||||
moreProps={rowMenu.buttonProps(f)}
|
||||
menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
|
||||
arrived={ringed === f.id}
|
||||
/>
|
||||
))}
|
||||
</NavGrid>
|
||||
<div className="flex justify-end pt-3">
|
||||
<Pager {...pagerProps} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const body =
|
||||
filesQ.isError && !filesQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => filesQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : filesQ.isLoading ? (
|
||||
<LoadingState shape="list" />
|
||||
<LoadingState shape={isGrid ? "grid" : "list"} />
|
||||
) : isGrid ? (
|
||||
grid
|
||||
) : (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(f) => String(f.id)}
|
||||
persistKey={LS.filesTable}
|
||||
persistKey={accountKey(LS.filesTable) ?? undefined}
|
||||
pageSize={50}
|
||||
keyboardNav
|
||||
fixedLayout
|
||||
rowProps={(f) => rowMenu.triggerProps(f)}
|
||||
emptyText={
|
||||
q
|
||||
? t("files.empty.search", { q })
|
||||
: t(
|
||||
shown.kind === "shared"
|
||||
? "files.empty.shared"
|
||||
: shown.kind === "folder"
|
||||
? "files.empty.folder"
|
||||
: "files.empty.all",
|
||||
)
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
reveal={reveal}
|
||||
narrowedBy={q}
|
||||
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
|
||||
rowClassName={(f) =>
|
||||
ringed === f.id ? "bg-accent/8 ring-2 ring-inset ring-accent/60" : ""
|
||||
}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
);
|
||||
|
||||
const layoutOptions: ViewOption<FilesLayout>[] = LAYOUTS.map((id) => ({
|
||||
id,
|
||||
label: t(`files.layout.${id}`),
|
||||
icon: LAYOUT_ICON[id],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-3 gap-y-2 px-4 pt-2 pb-2">
|
||||
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
|
||||
{renamingFolder && open?.kind === "own" ? (
|
||||
<RenameField
|
||||
initial={open.name ?? ""}
|
||||
label={t("files.folder.rename")}
|
||||
className="w-64 max-w-full"
|
||||
onDone={(name, byKey) => {
|
||||
refocusFolderMore.current = byKey;
|
||||
setRenamingFolder(false);
|
||||
if (name !== null) folderActions.rename(open, name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h2 className="min-w-0 truncate text-lg font-bold">{title}</h2>
|
||||
)}
|
||||
{open && !renamingFolder && (
|
||||
<MoreButton
|
||||
ref={folderMore}
|
||||
label={t("files.folder.menu")}
|
||||
size="sm"
|
||||
expanded={folderMenu.open}
|
||||
menuProps={folderMenu.buttonProps(open)}
|
||||
/>
|
||||
)}
|
||||
{filesQ.data && (
|
||||
<span className="text-xs tabular-nums text-muted">
|
||||
{t("files.count", { count: rows.length })}
|
||||
</span>
|
||||
)}
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload(true)}
|
||||
className="ml-auto shrink-0"
|
||||
>
|
||||
{t("files.actions.upload")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Wraps on a phone: sort, view and Upload together are wider than the column. */}
|
||||
<span className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
{isGrid && (
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("files.sort.label")}
|
||||
defaultLabel={t("files.sort.default")}
|
||||
ascLabel={t("files.sort.asc")}
|
||||
descLabel={t("files.sort.desc")}
|
||||
/>
|
||||
)}
|
||||
<ViewSwitcher
|
||||
value={layout}
|
||||
options={layoutOptions}
|
||||
onChange={(next) => setLayouts({ ...layouts, [viewKey]: next })}
|
||||
label={t("files.layout.label")}
|
||||
/>
|
||||
{canUpload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<UploadCloud className="w-4 h-4" />}
|
||||
onClick={() => setUpload({ file: null })}
|
||||
>
|
||||
{t("files.actions.upload")}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
<div className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
|
||||
<div ref={listRef} className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
|
||||
{body}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
|
||||
<ContextMenu
|
||||
menu={folderMenu}
|
||||
ariaLabel={t("files.folder.menu")}
|
||||
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
{upload && (
|
||||
<UploadDialog
|
||||
folderId={uploadFolder(shown, folders)}
|
||||
onClose={() => setUpload(false)}
|
||||
folderId={uploadTarget}
|
||||
initialFile={upload.file}
|
||||
onClose={() => setUpload(null)}
|
||||
/>
|
||||
)}
|
||||
{metaFile && <MetaEditModal job={metaFile} onClose={() => setMetaFile(null)} />}
|
||||
@@ -473,6 +633,21 @@ export default function Files() {
|
||||
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
|
||||
</Suspense>
|
||||
</div>
|
||||
{dragging && (
|
||||
// Through <Overlay>: declared in the page, a `fixed` layer is trapped in its stacking
|
||||
// context and the rail paints over it.
|
||||
<Overlay>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-3 z-overlay grid place-items-center rounded-2xl border-2 border-dashed border-accent bg-accent/10 backdrop-blur-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 rounded-xl bg-card px-4 py-3 text-base font-semibold shadow-float">
|
||||
<UploadCloud className="size-5 text-accent" />
|
||||
{t("files.drop.here", { folder: uploadTargetName })}
|
||||
</span>
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user