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:
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { LS, getAccountRaw, setAccountRaw } from "../lib/storage";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./navigationContext";
|
||||
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";
|
||||
@@ -22,6 +25,11 @@ import {
|
||||
// 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 (
|
||||
@@ -31,9 +39,16 @@ function initialView(): FilesView {
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -42,10 +57,48 @@ export function FilesProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror({ folder: page === "files" ? viewParam(view) : null });
|
||||
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;
|
||||
|
||||
const state = useMemo<FilesState>(() => ({ search, view }), [search, view]);
|
||||
const actions = useMemo<FilesActions>(() => ({ setSearch, setView }), [setSearch, setView]);
|
||||
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}>
|
||||
|
||||
Reference in New Issue
Block a user