Files
siftlode/frontend/src/components/FilesProvider.tsx
T
peter d22c64f4dd 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.
2026-10-11 15:59:12 +02:00

109 lines
4.4 KiB
TypeScript

import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation, useNavigationActions } from "./navigationContext";
import {
ALL_FILES,
FilesActionsContext,
FilesStateContext,
parseItem,
parseView,
viewParam,
type FilesActions,
type FilesArrival,
type FilesFocus,
type FilesState,
type FilesView,
} from "./filesContext";
// Owns the Files module's shared state — the header search term and the view the rail picked (every
// file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page
// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in
// main.tsx around <App/>.
//
// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per
// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder.
// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the
// page's to fall back from (it knows the folder list).
//
// An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it
// has the file in view, the way the channel manager's focusChannel is: the consumed marker lives here,
// because the page chunk remounts on every navigation and would otherwise re-apply it. Until then the
// address carries it, so a reload mid-arrival still lands on the file.
function initialView(): FilesView {
return (
parseView(new URLSearchParams(window.location.search).get("folder")) ??
parseView(getAccountRaw(LS.filesView)) ??
ALL_FILES
);
}
function initialFocus(): FilesFocus | null {
const id = parseItem(new URLSearchParams(window.location.search).get("item"));
return id === null ? null : { id, token: 1 };
}
export function FilesProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [view, setViewState] = useState<FilesView>(initialView);
const [focusRaw, setFocusRaw] = useState<FilesFocus | null>(initialFocus);
const [focusApplied, setFocusApplied] = useState(0);
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setView = useCallback((next: FilesView) => {
setViewState(next);
setAccountRaw(LS.filesView, viewParam(next));
}, []);
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const openFile = useCallback(
(id: number, folderId: number | null) => {
setView(folderId === null ? ALL_FILES : { kind: "folder", id: folderId });
// A search that hides the file would leave nothing to arrive at.
setSearchState("");
setFocusRaw((prev) => ({ id, token: (prev?.token ?? 0) + 1 }));
setPage("files");
},
[setView, setPage],
);
// What the page found. Held HERE, and set only once the page has mounted and looked: the table and
// the grid follow a reveal that CHANGES under them, never one they mount with (a remount must not
// move a page nobody asked to move), so an arrival decided during the page's first render would be
// one they never turn to.
const [arrivedRaw, setArrivedRaw] = useState<FilesArrival | null>(null);
const consumeFocus = useCallback(
(at: string | null) => {
setFocusApplied(focusRaw?.token ?? 0);
setArrivedRaw(focusRaw && at !== null ? { ...focusRaw, view: at } : null);
},
[focusRaw],
);
// Only ON the Files page, and only until the page has used it.
const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null;
// Leaving the page ends the arrival: back on it later, nothing is rung or turned to.
if (page !== "files" && arrivedRaw) setArrivedRaw(null);
const arrived = page === "files" ? arrivedRaw : null;
useUrlMirror({
folder: page === "files" ? viewParam(view) : null,
item: focus ? String(focus.id) : null,
});
const state = useMemo<FilesState>(
() => ({ search, view, focus, arrived }),
[search, view, focus, arrived],
);
const actions = useMemo<FilesActions>(
() => ({ setSearch, setView, openFile, consumeFocus }),
[setSearch, setView, openFile, consumeFocus],
);
return (
<FilesActionsContext.Provider value={actions}>
<FilesStateContext.Provider value={state}>{children}</FilesStateContext.Provider>
</FilesActionsContext.Provider>
);
}