- 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.
109 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|