From 97ca940360457d55c42097346f44c075239a0d9a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Thu, 22 Oct 2026 23:12:56 +0200 Subject: [PATCH 1/4] feat: a way back from the upload screen The Back arrow moves into a shared PageBack, used by the tag editor and now the upload screen. Upload goes back where it was started; reached by a full load, it goes to the folder it files into, or home when it files into none. Signed-off-by: InstaZDLL --- webapp/e2e/studio-nocturne.spec.ts | 28 ++++++++++++++++++ webapp/src/components/page-back.tsx | 39 ++++++++++++++++++++++++++ webapp/src/components/track-editor.tsx | 28 ++++-------------- webapp/src/pages/upload-page.tsx | 16 +++++++++++ 4 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 webapp/src/components/page-back.tsx diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 2ef799c..cc49e19 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -5384,3 +5384,31 @@ test("goes back from the tag editor to where it was opened", async ({ await page.getByRole("button", { name: "Back", exact: true }).click(); await expect(page).toHaveURL(/\/songs$/); }); + +/** + * The upload screen goes back where the upload was started. Reached by a + * full load, with nothing behind it, it goes to the folder it files into — + * or home, when it files into none. + */ +test("goes back from the upload screen to the folder it was opened for", async ({ + page, +}) => { + libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; + const state = await serveFiledFolders(page, []); + state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" }); + const back = page.getByRole("button", { name: "Back", exact: true }); + + await page.goto("/folders?view=filed&folder=folder-1"); + await page.getByRole("link", { name: "Upload here" }).click(); + await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible(); + await back.click(); + await expect(page.getByRole("heading", { name: "Rock" })).toBeVisible(); + + await page.goto("/upload?folder=folder-1"); + await back.click(); + await expect(page).toHaveURL(/\/folders\?view=filed&folder=folder-1$/); + + await page.goto("/upload"); + await back.click(); + await expect(page).toHaveURL(/\/$/); +}); diff --git a/webapp/src/components/page-back.tsx b/webapp/src/components/page-back.tsx new file mode 100644 index 0000000..a12ac4d --- /dev/null +++ b/webapp/src/components/page-back.tsx @@ -0,0 +1,39 @@ +import { useNavigate, useRouter } from "@tanstack/react-router"; + +import { useI18n } from "../state/i18n"; +import { Icon } from "./icons"; + +type Navigate = ReturnType; + +/** + * Back to wherever the page was opened from, above its header. + * + * The router's own history, not the browser's: after a full load from a typed + * address the router has nothing behind it, while the browser may still hold + * a page of another visit — going there would leave the place the visitor + * meant to come back from. With nowhere to go back to, `fallback` names the + * nearest place that makes sense for the page. + */ +export function PageBack({ + fallback, +}: { + fallback: (navigate: Navigate) => Promise; +}) { + const { t } = useI18n(); + const router = useRouter(); + const navigate = useNavigate(); + return ( + + ); +} diff --git a/webapp/src/components/track-editor.tsx b/webapp/src/components/track-editor.tsx index 2383b4f..b00dd55 100644 --- a/webapp/src/components/track-editor.tsx +++ b/webapp/src/components/track-editor.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link, useNavigate, useRouter } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router"; import { type FormEvent, useState } from "react"; import { @@ -24,7 +24,7 @@ import { } from "../lib/track-corrections"; import { type TranslationKey, useI18n } from "../state/i18n"; import { CanvasPanel } from "./canvas-panel"; -import { Icon } from "./icons"; +import { PageBack } from "./page-back"; import { Loading, PageHeader } from "./page-shell"; const LABELS: Record = { @@ -64,28 +64,12 @@ export function TrackEditorPage({ trackId }: { trackId: string }) { /** * Back to wherever the editor was opened from — a song row, an album, the - * track menu. Opened from a typed address there is no "where", and the songs - * list is the nearest place that holds the track. Drawn while the track loads - * and when it fails to, so the way out never waits on the answer. + * track menu — or to the songs list, the nearest place that holds the track. + * Drawn while the track loads and when it fails to, so the way out never + * waits on the answer. */ function BackButton() { - const { t } = useI18n(); - const router = useRouter(); - const navigate = useNavigate(); - return ( - - ); + return navigate({ to: "/songs" })} />; } function CorrectionForm({ initial }: { initial: Loaded }) { diff --git a/webapp/src/pages/upload-page.tsx b/webapp/src/pages/upload-page.tsx index a32c530..b905135 100644 --- a/webapp/src/pages/upload-page.tsx +++ b/webapp/src/pages/upload-page.tsx @@ -12,6 +12,7 @@ import { type UploadSessionState, } from "../api"; import { filedTreeQuery } from "../api/queries"; +import { PageBack } from "../components/page-back"; import { PageHeader } from "../components/page-shell"; import type { HashRequest, HashResponse } from "../lib/hash-worker"; import { @@ -376,9 +377,22 @@ export function UploadPage({ folder }: { folder?: string } = {}) { return t(STATUS_TEXT[item.status]); } + // Back to where the upload was started: the folder it files into, or the + // home screen when it was reached from nowhere in particular. + const back = ( + + folder + ? navigate({ to: "/folders", search: { view: "filed", folder } }) + : navigate({ to: "/" }) + } + /> + ); + if (!active) { return (
+ {back}

{t("upload.noLibrary")}

@@ -392,6 +406,7 @@ export function UploadPage({ folder }: { folder?: string } = {}) { const mayWrite = active.role === "owner" || active.role === "manager"; return (
+ {back} + {back} Date: Thu, 22 Oct 2026 23:22:40 +0200 Subject: [PATCH 2/4] feat: one folder view, the filed folders The Folders page drew the disk tree beside the filed one, and a file uploaded into a folder showed there and in Received at once: true twice, read as the file manager contradicting itself. The page now draws the filed tree only, where a track has one place, its folder or Unfiled. The disk tree route stays, unchanged, for other clients; its client code, the view switch and their translations go. RFC-012 records why under decision 1. Signed-off-by: InstaZDLL --- docs/rfcs/RFC-012-library-browser.md | 19 +- webapp/e2e/studio-nocturne.spec.ts | 149 +------ webapp/src/api/index.ts | 6 - webapp/src/api/libraries.ts | 52 --- webapp/src/api/queries.ts | 19 - webapp/src/components/filed-folders.tsx | 482 -------------------- webapp/src/components/song-table.tsx | 9 +- webapp/src/main.tsx | 20 +- webapp/src/pages/folders-page.tsx | 560 +++++++++++++++++------- webapp/src/pages/upload-page.tsx | 2 +- webapp/src/state/i18n.tsx | 18 - webapp/src/styles/styles.css | 6 +- 12 files changed, 428 insertions(+), 914 deletions(-) delete mode 100644 webapp/src/components/filed-folders.tsx diff --git a/docs/rfcs/RFC-012-library-browser.md b/docs/rfcs/RFC-012-library-browser.md index 8f353fb..f64bb97 100644 --- a/docs/rfcs/RFC-012-library-browser.md +++ b/docs/rfcs/RFC-012-library-browser.md @@ -23,7 +23,9 @@ skipped, and lyrics downloads that are never rendered. Last, listeners' access to the disk tree, settled in decision 3 rather than left open. And where Properties reads its lyrics from, and how the lyrics route projects - the order. + the order. Then, 2026-10-22, after use: the web client draws the filed + tree only, and the disk tree stays an API (see "What the web client + draws" under decision 1). - **Authors**: the WaveFlow project - **Depends on**: [RFC-002](RFC-002-waveflow-server-v2.md), [RFC-007](RFC-007-library-event-stream.md), @@ -114,6 +116,21 @@ may read the track may read where it lives in the library. The **root** of the library — the absolute path on the server — is never returned, to anyone. A path is always relative to its library. +### What the web client draws + +The two trees shipped side by side (#301, #302), and the owner read them +together on the first day. A file uploaded into the folder **Test** showed +in **Test** under *Filed* and in **Received** under *On disk*: true twice, +and read as the file manager contradicting itself. Two trees whose titles say +which one can be changed did not stop a person from asking where the file +*is*, and each answered differently. + +So the web client draws **the filed tree only**. A track has one place on +its Folders page: its folder, or **Unfiled**. The disk tree stays what this +decision defines — `GET /libraries/{id}/tree`, read-only, open to members — +for any client that wants to show where files lie, and its contract does not +change. A client that draws both should not put them on one screen. + ## Decision 2 — a filed folder is a place, not a collection A track sits in **at most one filed folder** per library. Moving it to another diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index cc49e19..642240f 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -3434,7 +3434,7 @@ test("opens the rest of the client from the mobile bar", async ({ await expect(sheet).toBeVisible(); // The twelve the bar cannot hold — including the two this account is // entitled to as an administrator of a library that takes files. `Radio` - // joined them with #217, and `Folders` with RFC-012's disk tree; this count + // joined them with #217, and `Folders` with RFC-012's filed folders; this count // is what said so: a new entry in the sidebar reaches the sheet, and the // sheet is pinned. for (const name of [ @@ -4887,128 +4887,6 @@ test("closes the form while a station is saving, and leaves listening alone", as await expect(page.getByLabel("Station name")).toHaveValue(""); }); -/** - * The disk tree (RFC-012 decision 1): one folder at a time, the folder in the - * address, a trail back up, the received folder under its own name, and the - * file names under each title. Read-only: no control here moves anything. - */ -test("walks a library's folders on disk and back up the trail", async ({ - page, -}) => { - libraries = [{ ...library("library-1", "Ma musique"), accepts_uploads: true }]; - const folder = (name: string, path: string, direct: number, nested: number) => ({ - name, - path, - track_count: direct, - nested_track_count: nested, - }); - const listings: Record = { - "": { - path: "", - folder_total: 2, - track_total: 0, - folders: [ - folder(".waveflow-managed", ".waveflow-managed", 1, 1), - folder("Björk", "Björk", 0, 2), - ], - tracks: [], - }, - Björk: { - path: "Björk", - folder_total: 1, - track_total: 0, - folders: [folder("Vespertine", "Björk/Vespertine", 2, 2)], - tracks: [], - }, - "Björk/Vespertine": { - path: "Björk/Vespertine", - folder_total: 0, - track_total: 2, - folders: [], - tracks: [ - { name: "01 Hidden Place.flac", song: song(1, "Hidden Place", 0, false) }, - { name: "02 Cocoon.flac", song: song(2, "Cocoon", 0, false) }, - ], - }, - ".waveflow-managed": { - path: ".waveflow-managed", - folder_total: 0, - track_total: 1, - folders: [], - tracks: [{ name: "9ad0f9e.flac", song: song(3, "Pagan Poetry", 0, false) }], - }, - }; - const asked: string[] = []; - await page.route("**/api/v2/libraries/library-1/tree?*", (route) => { - const path = new URL(route.request().url()).searchParams.get("path") ?? ""; - asked.push(path); - const listing = listings[path]; - return listing - ? route.fulfill({ json: listing }) - : route.fulfill({ status: 404, json: { error: "not_found" } }); - }); - - await page.goto("/folders"); - await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); - await expect(page.getByText("Nothing here moves a file.", { exact: false })).toBeVisible(); - const folders = page.getByRole("list", { name: "Folders" }); - // The received folder by its label, and an artist folder by what is beneath. - await expect(folders.getByRole("link", { name: /Received/ })).toBeVisible(); - await expect(folders.getByRole("link", { name: /Björk/ })).toContainText("2 tracks"); - await expect(page.getByRole("link", { name: "Upload music" })).toHaveAttribute( - "href", - "/upload", - ); - - await folders.getByRole("link", { name: /Björk/ }).click(); - await expect(page).toHaveURL(/path=Bj%C3%B6rk$/); - await page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Vespertine/ }).click(); - await expect(page.getByRole("heading", { name: "Vespertine" })).toBeVisible(); - const trail = page.getByRole("navigation", { name: "Folder path" }); - await expect(trail.getByRole("link")).toHaveText(["Ma musique", "Björk", "Vespertine"]); - await expect(trail.getByRole("link", { name: "Vespertine" })).toHaveAttribute( - "aria-current", - "page", - ); - // The file's name stands under each title. - const rows = page.locator(".songs tbody tr"); - await expect(rows).toHaveCount(2); - await expect(rows.first()).toContainText("Hidden Place"); - await expect(rows.first()).toContainText("01 Hidden Place.flac"); - await expectNoWcagViolations(page); - - // Back up the trail to the root, then into the received files. - await trail.getByRole("link", { name: "Ma musique" }).click(); - await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); - await page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Received/ }).click(); - await expect(page.getByRole("heading", { name: "Received" })).toBeVisible(); - await expect(page.locator(".songs tbody tr")).toContainText("9ad0f9e.flac"); - expect([...new Set(asked)]).toEqual(["", "Björk", "Björk/Vespertine", ".waveflow-managed"]); -}); - -/** A listener walks the same tree and is offered no upload. */ -test("offers a listener the folders without the upload", async ({ page }) => { - libraries = [ - { ...library("library-1", "Ma musique"), role: "listener", accepts_uploads: true }, - ]; - await page.route("**/api/v2/libraries/library-1/tree?*", (route) => - route.fulfill({ - json: { - path: "", - folder_total: 1, - track_total: 0, - folders: [{ name: "Björk", path: "Björk", track_count: 0, nested_track_count: 2 }], - tracks: [], - }, - }), - ); - await page.goto("/folders"); - await expect( - page.getByRole("list", { name: "Folders" }).getByRole("link", { name: /Björk/ }), - ).toBeVisible(); - await expect(page.getByRole("link", { name: "Upload music" })).toHaveCount(0); -}); - /** * A stand-in for the filed-folder routes (RFC-012 decision 2), with state: the * screen re-reads after every write, and the point is that it shows what the @@ -5128,13 +5006,8 @@ test("files tracks into folders from the menu and by dragging", async ({ ]; const state = await serveFiledFolders(page, songs); - await page.goto("/folders?view=filed"); - await expect(page.getByRole("heading", { name: "Filed" })).toBeVisible(); - await expect( - page.getByRole("navigation", { name: "Folder views" }).getByRole("link", { - name: "Filed", - }), - ).toHaveAttribute("aria-current", "page"); + await page.goto("/folders"); + await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); const cards = page.getByRole("list", { name: "Folders" }); await expect(cards.getByRole("link", { name: /Unfiled/ })).toContainText("3 tracks"); @@ -5184,7 +5057,7 @@ test("files tracks into folders from the menu and by dragging", async ({ } // Inside the folder: both, renamed, then deleted, and they come back unfiled. - await page.goto("/folders?view=filed&folder=folder-1"); + await page.goto("/folders?folder=folder-1"); await expect(page.getByRole("heading", { name: "Rock" })).toBeVisible(); await expect(rows).toHaveCount(2); await page.getByRole("button", { name: "Rename" }).click(); @@ -5193,7 +5066,7 @@ test("files tracks into folders from the menu and by dragging", async ({ await expect(page.getByRole("heading", { name: "Rock & Roll" })).toBeVisible(); page.once("dialog", (dialog) => void dialog.accept()); await page.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByRole("heading", { name: "Filed" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); await expect(page.getByRole("status")).toHaveText( "Folder deleted: 2 tracks are unfiled.", ); @@ -5208,7 +5081,7 @@ test("shows a listener the filed tree with nothing to change", async ({ page }) const state = await serveFiledFolders(page, songs); state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" }); - await page.goto("/folders?view=filed&folder=unfiled"); + await page.goto("/folders?folder=unfiled"); await expect(page.locator(".songs tbody tr")).toHaveCount(1); for (const name of ["New folder", "Rename", "Delete", "Move…"]) { await expect(page.getByRole("button", { name })).toHaveCount(0); @@ -5313,15 +5186,15 @@ test("offers no tracks at the root after visiting the unfiled ones", async ({ page, }) => { await serveFiledFolders(page, [song(1, "Hidden Place", 0, false)]); - await page.goto("/folders?view=filed&folder=unfiled"); + await page.goto("/folders?folder=unfiled"); await expect(page.locator(".songs tbody tr")).toHaveCount(1); const controls = page.locator(".page-controls"); await expect(controls.getByRole("button", { name: /Play/ })).toBeVisible(); await page .getByRole("navigation", { name: "Folder path" }) - .getByRole("link", { name: "Filed", exact: true }) + .getByRole("link", { name: "Folders", exact: true }) .click(); - await expect(page.getByRole("heading", { name: "Filed" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Folders" })).toBeVisible(); await expect(controls.getByRole("button", { name: /Play/ })).toHaveCount(0); await expect(page.getByRole("button", { name: "Load more" })).toHaveCount(0); }); @@ -5398,7 +5271,7 @@ test("goes back from the upload screen to the folder it was opened for", async ( state.folders.push({ id: "folder-1", parent_id: null, name: "Rock" }); const back = page.getByRole("button", { name: "Back", exact: true }); - await page.goto("/folders?view=filed&folder=folder-1"); + await page.goto("/folders?folder=folder-1"); await page.getByRole("link", { name: "Upload here" }).click(); await expect(page.getByText("Received files go into the folder Rock.")).toBeVisible(); await back.click(); @@ -5406,7 +5279,7 @@ test("goes back from the upload screen to the folder it was opened for", async ( await page.goto("/upload?folder=folder-1"); await back.click(); - await expect(page).toHaveURL(/\/folders\?view=filed&folder=folder-1$/); + await expect(page).toHaveURL(/\/folders\?folder=folder-1$/); await page.goto("/upload"); await back.click(); diff --git a/webapp/src/api/index.ts b/webapp/src/api/index.ts index 5caf7e5..5d391f5 100644 --- a/webapp/src/api/index.ts +++ b/webapp/src/api/index.ts @@ -83,22 +83,16 @@ export { updateFolder, } from "./folders"; export type { - DiskFolder, - DiskListing, - DiskTrack, Library, LibraryMember, ScanJob, } from "./libraries"; export { addLibrary, - DISK_TREE_PAGE, drainEventStream, - getDiskTree, getScan, listLibraries, listLibraryMembers, - RECEIVED_FOLDER, removeLibraryMember, setLibraryMember, startScan, diff --git a/webapp/src/api/libraries.ts b/webapp/src/api/libraries.ts index 3aee261..f221356 100644 --- a/webapp/src/api/libraries.ts +++ b/webapp/src/api/libraries.ts @@ -1,4 +1,3 @@ -import type { Song } from "./catalog"; import { call, renewFor, session, sessionGeneration } from "./client"; export type Library = { @@ -22,57 +21,6 @@ export type Library = { export const listLibraries = () => call("/api/v2/libraries"); -/** - * The folder RFC-008 writes received files into, under its real path. The - * client draws it as **Received**; the path is what addresses it. - */ -export const RECEIVED_FOLDER = ".waveflow-managed"; - -export type DiskFolder = { - name: string; - /** Relative to the library, segments joined by `/`. */ - path: string; - /** The tracks directly in it. */ - track_count: number; - /** The tracks anywhere beneath it, its own included. */ - nested_track_count: number; -}; - -/** A track under the name its file has. */ -export type DiskTrack = { name: string; song: Song }; - -/** - * One level of a library's disk tree (RFC-012 decision 1). A page runs over - * the folders, then the tracks; the totals say how long each part is. - */ -export type DiskListing = { - path: string; - folder_total: number; - track_total: number; - folders: DiskFolder[]; - tracks: DiskTrack[]; -}; - -/** How many entries, folders and tracks together, one page carries. */ -export const DISK_TREE_PAGE = 200; - -/** One page of the folder at `path`: empty for the library's root. */ -export const getDiskTree = ( - libraryId: string, - path: string, - offset: number, - limit = DISK_TREE_PAGE, - signal?: AbortSignal, -) => - call( - `/api/v2/libraries/${libraryId}/tree?${new URLSearchParams({ - path, - offset: String(offset), - limit: String(limit), - })}`, - { signal }, - ); - export const addLibrary = ( name: string, path: string, diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries.ts index 0fdcb59..84243e7 100644 --- a/webapp/src/api/queries.ts +++ b/webapp/src/api/queries.ts @@ -5,11 +5,9 @@ import { type ArtistDetail, artistTopSongs, canvasUrl, - DISK_TREE_PAGE, FOLDER_TRACKS_PAGE, getAlbum, getArtist, - getDiskTree, getLyrics, getTrack, getTrackCredits, @@ -213,23 +211,6 @@ export const trackPropertiesQuery = (trackId: string) => }, }); -/** - * One folder of a library's disk tree, a page at a time: folders first, then - * tracks, as the route pages them. Unlike the songs listing this one knows - * its length, so the last page is the one that reaches the totals. - */ -export const diskTreeQuery = (libraryId: string, path: string) => - infiniteQueryOptions({ - queryKey: ["disk-tree", libraryId, path], - queryFn: ({ pageParam, signal }) => - getDiskTree(libraryId, path, pageParam, DISK_TREE_PAGE, signal), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - lastParam + DISK_TREE_PAGE < last.folder_total + last.track_total - ? lastParam + DISK_TREE_PAGE - : undefined, - }); - /** * A library's whole filed tree: bounded by the server's folder cap, so it * is held whole rather than paged. diff --git a/webapp/src/components/filed-folders.tsx b/webapp/src/components/filed-folders.tsx deleted file mode 100644 index 4330169..0000000 --- a/webapp/src/components/filed-folders.tsx +++ /dev/null @@ -1,482 +0,0 @@ -import { - useInfiniteQuery, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; -import { Link, useNavigate } from "@tanstack/react-router"; -import { - type DragEvent, - type FormEvent, - type ReactNode, - useMemo, - useState, -} from "react"; - -import { - ApiError, - createFolder, - deleteFolder, - type FiledFolder, - folderTrail, - isWithin, - placeTracks, - UNFILED, - updateFolder, -} from "../api"; -import { filedTreeQuery, folderTracksQuery } from "../api/queries"; -import { usePlayer } from "../player/player"; -import { useI18n } from "../state/i18n"; -import { - mayCorrectTracks, - mayUploadTo, - useLibraryScope, -} from "../state/library-scope"; -import { FolderPicker } from "./folder-picker"; -import { Icon } from "./icons"; -import { Loading, PageHeader } from "./page-shell"; -import { SongTable, TRACK_DRAG_TYPE } from "./song-table"; - -/** What a dragged folder card carries: its id. */ -const FOLDER_DRAG_TYPE = "application/x-waveflow-folder"; - -type Notice = { tone: "status" | "error"; text: string } | null; - -/** - * The library as its owner and managers sort it (RFC-012 decision 2): folders - * that exist only in WaveFlow, a track in at most one of them. - * - * `folder` is empty for the root, `unfiled` for the tracks filed nowhere, and - * otherwise a folder id. Any member walks it; owners and managers also create, - * rename, move and delete folders, and file tracks — from the track menu, by - * dragging a row onto a folder, or by dragging a folder onto another. Every - * drag has a keyboard twin: the menu's "Move to folder…" and the folder's own - * "Move…". - */ -export function FiledFolders({ - folder, - switcher, -}: { - folder: string; - switcher: ReactNode; -}) { - const { t } = useI18n(); - const player = usePlayer(); - const navigate = useNavigate(); - const client = useQueryClient(); - const { active, ready } = useLibraryScope(); - const libraryId = active?.id ?? ""; - const may = mayCorrectTracks(active ?? undefined); - const tree = useQuery({ - ...filedTreeQuery(libraryId), - enabled: active !== null, - }); - const listsTracks = folder !== ""; - const tracks = useInfiniteQuery({ - ...folderTracksQuery(libraryId, folder || UNFILED), - enabled: active !== null && listsTracks, - }); - // A disabled query still answers from its cache: at the root, the unfiled - // tracks read on the way in would otherwise be offered to play and to page. - const songs = useMemo( - () => (listsTracks ? (tracks.data?.pages.flat() ?? []) : []), - [listsTracks, tracks.data], - ); - const morePages = listsTracks && tracks.hasNextPage; - const [notice, setNotice] = useState(null); - const [form, setForm] = useState<"create" | "rename" | null>(null); - const [moving, setMoving] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - - const folders = tree.data?.folders ?? []; - const isReal = folder !== "" && folder !== UNFILED; - const trail = isReal ? folderTrail(folders, folder) : []; - const here = trail.at(-1); - const missing = isReal && tree.data !== undefined && !here; - // A folder's sub-folders; at the root and beside the unfiled tracks, the - // root's own folders — there, the places an unfiled track can be dropped. - const children = folders.filter( - (entry) => entry.parent_id === (isReal ? folder : null), - ); - const title = - folder === UNFILED ? t("filed.unfiled") : (here?.name ?? t("filed.title")); - - async function refresh() { - await Promise.all([ - client.invalidateQueries({ queryKey: ["filed-tree", libraryId] }), - client.invalidateQueries({ queryKey: ["folder-tracks", libraryId] }), - ]); - } - - /** Runs a write, says how it went, and re-reads what it changed. */ - async function attempt( - write: () => Promise, - refused?: (error: ApiError) => string | null, - ) { - setNotice(null); - try { - const said = await write(); - await refresh(); - setNotice({ tone: "status", text: said }); - return true; - } catch (error) { - const text = - (error instanceof ApiError && refused?.(error)) || t("filed.failed"); - setNotice({ tone: "error", text }); - return false; - } - } - - const named = (error: ApiError) => - error.status === 409 - ? t("filed.nameTaken") - : error.status === 422 - ? t("filed.refused") - : null; - - function fileInto(target: FiledFolder | null, trackIds: string[]) { - void attempt(async () => { - const count = await placeTracks(libraryId, trackIds, target?.id ?? null); - return t("filed.filed", { - count, - folder: target?.name ?? t("filed.unfiled"), - }); - }); - } - - function moveFolder(moved: string, target: string | null) { - const name = folders.find((entry) => entry.id === moved)?.name ?? ""; - void attempt(async () => { - await updateFolder(moved, { parent_id: target }); - return t("filed.folderMoved", { name }); - }, named); - } - - // A drop target accepts a row, or a folder that is not itself or above it. - const dropProps = (target: FiledFolder | null) => { - if (!may) return {}; - const key = target?.id ?? UNFILED; - const accepts = (event: DragEvent) => - event.dataTransfer.types.includes(TRACK_DRAG_TYPE) || - (target !== null && event.dataTransfer.types.includes(FOLDER_DRAG_TYPE)); - return { - onDragOver: (event: DragEvent) => { - if (!accepts(event)) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - setDropTarget(key); - }, - onDragLeave: () => - setDropTarget((current) => (current === key ? null : current)), - onDrop: (event: DragEvent) => { - setDropTarget(null); - const rows = event.dataTransfer.getData(TRACK_DRAG_TYPE); - const dragged = event.dataTransfer.getData(FOLDER_DRAG_TYPE); - if (rows) { - event.preventDefault(); - fileInto(target, JSON.parse(rows) as string[]); - } else if ( - dragged && - target && - !isWithin(folders, target.id, dragged) - ) { - event.preventDefault(); - moveFolder(dragged, target.id); - } - }, - }; - }; - - async function submitName(event: FormEvent) { - event.preventDefault(); - const name = String(new FormData(event.currentTarget).get("name") ?? ""); - const done = await attempt(async () => { - if (form === "rename" && here) { - const renamed = await updateFolder(here.id, { name }); - return t("filed.renamed", { name: renamed.name }); - } - const created = await createFolder( - libraryId, - name, - isReal ? folder : null, - ); - return t("filed.created", { name: created.name }); - }, named); - if (done) setForm(null); - } - - async function remove() { - if (!here || !window.confirm(t("filed.confirmDelete", { name: here.name }))) - return; - const parent = here.parent_id; - const done = await attempt(async () => { - const { unfiled } = await deleteFolder(here.id); - return t("filed.deleted", { count: unfiled }); - }); - if (done) { - await navigate({ - to: "/folders", - search: { view: "filed", folder: parent ?? "" }, - }); - } - } - - const link = (target: string) => ({ - to: "/folders" as const, - search: { view: "filed" as const, folder: target }, - }); - - const controls = ( - <> - {songs.length ? ( - - ) : null} - {may && folder !== UNFILED && !missing ? ( - - ) : null} - {may && here ? ( - <> - - - - - ) : null} - {here && mayUploadTo(active) ? ( - - - {t("filed.uploadHere")} - - ) : null} - - ); - - const header = ( - - {controls} - - ); - - const crumbs = ( - - ); - - if (ready && !active) { - return ( -
- {header} - {switcher} -

{t("folders.noLibrary")}

-
- ); - } - - const card = ( - target: FiledFolder | null, - label: string, - count: number, - to: string, - ) => ( -
  • - { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(FOLDER_DRAG_TYPE, target.id); - event.dataTransfer.setData("text/plain", target.name); - } - : undefined - } - {...dropProps(target)} - > - - {label} - {t("common.tracks", { count })} - -
  • - ); - - return ( -
    - {header} - {switcher} - {crumbs} -

    {t("filed.note")}

    - {notice ? ( -

    - {notice.text} -

    - ) : null} - {form ? ( -
    void submitName(event)} - > - - - -
    - ) : null} - {missing ? ( -

    {t("filed.missing")}

    - ) : !tree.data ? ( - - ) : ( - <> - {(folder !== UNFILED || may) && (children.length || folder === "") ? ( -
      - {children.map((entry) => - card(entry, entry.name, entry.track_count, entry.id), - )} - {folder === "" - ? card( - null, - t("filed.unfiled"), - tree.data.unfiled_track_count, - UNFILED, - ) - : null} -
    - ) : null} - {listsTracks ? ( - !tracks.data ? ( - - ) : songs.length ? ( - - ) : !children.length || folder === UNFILED ? ( -

    - {t(folder === UNFILED ? "filed.allFiled" : "filed.empty")} -

    - ) : null - ) : null} - {morePages ? ( -
    - - {tracks.isFetchNextPageError ? ( -

    - {t("songs.loadMoreError")} -

    - ) : null} -
    - ) : null} - - )} - {moving && here ? ( - { - await updateFolder(here.id, { parent_id: target }); - await refresh(); - setNotice({ - tone: "status", - text: t("filed.folderMoved", { name: here.name }), - }); - }} - onClose={() => setMoving(false)} - /> - ) : null} -
    - ); -} diff --git a/webapp/src/components/song-table.tsx b/webapp/src/components/song-table.tsx index fe03e55..be86d71 100644 --- a/webapp/src/components/song-table.tsx +++ b/webapp/src/components/song-table.tsx @@ -68,17 +68,11 @@ export function SongTable({ songs, numbering = "position", variant = "default", - captions, draggableRows = false, }: { songs: Song[]; numbering?: "track" | "position"; variant?: "default" | "album"; - /** - * What each row says under its title, in place of the album: the file's - * name, where the screen is about files. - */ - captions?: string[]; /** * Rows may be dragged onto a filed folder. Only where filing is offered: * a row that drags anywhere else is a gesture that does nothing. @@ -217,8 +211,7 @@ export function SongTable({ {song.title} - {captions?.[position] ?? - (variant === "album" ? song.artist : song.album)} + {variant === "album" ? song.artist : song.album} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 0991be6..439dad4 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -862,24 +862,14 @@ const adminRoute = createRoute({ const foldersRoute = createRoute({ getParentRoute: () => authedRoute, path: "/folders", - // Every key optional, so a link names only what it changes: the rail links - // to the disk root, a crumb names its path, a filed card its folder. - validateSearch: ( - search: Record, - ): { view?: "disk" | "filed"; path?: string; folder?: string } => ({ - view: search.view === "filed" ? "filed" : undefined, - path: typeof search.path === "string" ? search.path : undefined, + // The folder: absent or empty for the root, `unfiled` for the tracks filed + // nowhere, otherwise a folder id. Optional, so the rail links to the root. + validateSearch: (search: Record): { folder?: string } => ({ folder: typeof search.folder === "string" ? search.folder : undefined, }), component: function FoldersRoute() { - const { view, path, folder } = foldersRoute.useSearch(); - return ( - - ); + const { folder } = foldersRoute.useSearch(); + return ; }, }); diff --git a/webapp/src/pages/folders-page.tsx b/webapp/src/pages/folders-page.tsx index 2018421..bc82c0c 100644 --- a/webapp/src/pages/folders-page.tsx +++ b/webapp/src/pages/folders-page.tsx @@ -1,77 +1,224 @@ -import { useInfiniteQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { type ReactNode, useMemo } from "react"; +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { type DragEvent, type FormEvent, useMemo, useState } from "react"; -import { type DiskFolder, RECEIVED_FOLDER } from "../api"; -import { diskTreeQuery } from "../api/queries"; -import { FiledFolders } from "../components/filed-folders"; +import { + ApiError, + createFolder, + deleteFolder, + type FiledFolder, + folderTrail, + isWithin, + placeTracks, + UNFILED, + updateFolder, +} from "../api"; +import { filedTreeQuery, folderTracksQuery } from "../api/queries"; +import { FolderPicker } from "../components/folder-picker"; import { Icon } from "../components/icons"; import { Loading, PageHeader } from "../components/page-shell"; -import { SongTable } from "../components/song-table"; +import { SongTable, TRACK_DRAG_TYPE } from "../components/song-table"; import { usePlayer } from "../player/player"; import { useI18n } from "../state/i18n"; -import { mayUploadTo, useLibraryScope } from "../state/library-scope"; - -/** The folder's place in the tree, from the root down, each with its path. */ -function crumbsOf(path: string): Array<{ name: string; path: string }> { - if (!path) return []; - const segments = path.split("/"); - return segments.map((name, index) => ({ - name, - path: segments.slice(0, index + 1).join("/"), - })); -} +import { + mayCorrectTracks, + mayUploadTo, + useLibraryScope, +} from "../state/library-scope"; + +/** What a dragged folder card carries: its id. */ +const FOLDER_DRAG_TYPE = "application/x-waveflow-folder"; + +type Notice = { tone: "status" | "error"; text: string } | null; /** - * The active library as its files lie on disk (RFC-012 decision 1). + * The library as its owner and managers sort it (RFC-012 decision 2): folders + * that exist only in WaveFlow, a track in at most one of them. * - * Read-only, and said so: the tree is the indexed paths grouped, so it has no - * "new folder" and no "move" — those would change the owner's file system. - * The folder RFC-008 receives files into is drawn as **Received**; its path - * stays the real one, which is what the address carries. Upload leads to - * RFC-008's own screen, which decides where a received file goes. + * **The only folder view the web client draws.** The disk tree of decision 1 + * still answers at `GET /libraries/{id}/tree`, but beside these folders it + * showed a received track in "Received" while it sat in the folder it was + * uploaded into — two places for one track, and the owner read it as the + * file manager contradicting itself. Here a track has one place, its folder, + * or **Unfiled**. * - * The folder is in the address (`?path=`), so back and forward walk the tree - * and a folder can be linked to. + * `folder` is empty for the root, `unfiled` for the tracks filed nowhere, and + * otherwise a folder id. Any member walks it; owners and managers also create, + * rename, move and delete folders, and file tracks — from the track menu, by + * dragging a row onto a folder, or by dragging a folder onto another. Every + * drag has a keyboard twin: the menu's "Move to folder…" and the folder's own + * "Move…". */ -function DiskFolders({ - path, - switcher, -}: { - path: string; - switcher: ReactNode; -}) { +export function FoldersPage({ folder }: { folder: string }) { const { t } = useI18n(); const player = usePlayer(); + const navigate = useNavigate(); + const client = useQueryClient(); const { active, ready } = useLibraryScope(); - const { - data, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isFetchNextPageError, - } = useInfiniteQuery({ - ...diskTreeQuery(active?.id ?? "", path), + const libraryId = active?.id ?? ""; + const may = mayCorrectTracks(active ?? undefined); + const tree = useQuery({ + ...filedTreeQuery(libraryId), enabled: active !== null, }); - const folders = useMemo( - () => data?.pages.flatMap((page) => page.folders) ?? [], - [data], + const listsTracks = folder !== ""; + const tracks = useInfiniteQuery({ + ...folderTracksQuery(libraryId, folder || UNFILED), + enabled: active !== null && listsTracks, + }); + // A disabled query still answers from its cache: at the root, the unfiled + // tracks read on the way in would otherwise be offered to play and to page. + const songs = useMemo( + () => (listsTracks ? (tracks.data?.pages.flat() ?? []) : []), + [listsTracks, tracks.data], ); - const tracks = useMemo( - () => data?.pages.flatMap((page) => page.tracks) ?? [], - [data], + const morePages = listsTracks && tracks.hasNextPage; + const [notice, setNotice] = useState(null); + const [form, setForm] = useState<"create" | "rename" | null>(null); + const [moving, setMoving] = useState(false); + const [dropTarget, setDropTarget] = useState(null); + + const folders = tree.data?.folders ?? []; + const isReal = folder !== "" && folder !== UNFILED; + const trail = isReal ? folderTrail(folders, folder) : []; + const here = trail.at(-1); + const missing = isReal && tree.data !== undefined && !here; + // A folder's sub-folders; at the root and beside the unfiled tracks, the + // root's own folders — there, the places an unfiled track can be dropped. + const children = folders.filter( + (entry) => entry.parent_id === (isReal ? folder : null), ); - const songs = useMemo(() => tracks.map((track) => track.song), [tracks]); - const names = useMemo(() => tracks.map((track) => track.name), [tracks]); - const first = data?.pages[0]; + const title = + folder === UNFILED ? t("filed.unfiled") : (here?.name ?? t("nav.folders")); + + async function refresh() { + await Promise.all([ + client.invalidateQueries({ queryKey: ["filed-tree", libraryId] }), + client.invalidateQueries({ queryKey: ["folder-tracks", libraryId] }), + ]); + } + + /** Runs a write, says how it went, and re-reads what it changed. */ + async function attempt( + write: () => Promise, + refused?: (error: ApiError) => string | null, + ) { + setNotice(null); + try { + const said = await write(); + await refresh(); + setNotice({ tone: "status", text: said }); + return true; + } catch (error) { + const text = + (error instanceof ApiError && refused?.(error)) || t("filed.failed"); + setNotice({ tone: "error", text }); + return false; + } + } + + const named = (error: ApiError) => + error.status === 409 + ? t("filed.nameTaken") + : error.status === 422 + ? t("filed.refused") + : null; - const label = (folder: Pick) => - folder.path === RECEIVED_FOLDER ? t("folders.received") : folder.name; - const crumbs = crumbsOf(path); - const here = crumbs.at(-1); - const title = here ? label(here) : t("nav.folders"); + function fileInto(target: FiledFolder | null, trackIds: string[]) { + void attempt(async () => { + const count = await placeTracks(libraryId, trackIds, target?.id ?? null); + return t("filed.filed", { + count, + folder: target?.name ?? t("filed.unfiled"), + }); + }); + } + + function moveFolder(moved: string, target: string | null) { + const name = folders.find((entry) => entry.id === moved)?.name ?? ""; + void attempt(async () => { + await updateFolder(moved, { parent_id: target }); + return t("filed.folderMoved", { name }); + }, named); + } + + // A drop target accepts a row, or a folder that is not itself or above it. + const dropProps = (target: FiledFolder | null) => { + if (!may) return {}; + const key = target?.id ?? UNFILED; + const accepts = (event: DragEvent) => + event.dataTransfer.types.includes(TRACK_DRAG_TYPE) || + (target !== null && event.dataTransfer.types.includes(FOLDER_DRAG_TYPE)); + return { + onDragOver: (event: DragEvent) => { + if (!accepts(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setDropTarget(key); + }, + onDragLeave: () => + setDropTarget((current) => (current === key ? null : current)), + onDrop: (event: DragEvent) => { + setDropTarget(null); + const rows = event.dataTransfer.getData(TRACK_DRAG_TYPE); + const dragged = event.dataTransfer.getData(FOLDER_DRAG_TYPE); + if (rows) { + event.preventDefault(); + fileInto(target, JSON.parse(rows) as string[]); + } else if ( + dragged && + target && + !isWithin(folders, target.id, dragged) + ) { + event.preventDefault(); + moveFolder(dragged, target.id); + } + }, + }; + }; + + async function submitName(event: FormEvent) { + event.preventDefault(); + const name = String(new FormData(event.currentTarget).get("name") ?? ""); + const done = await attempt(async () => { + if (form === "rename" && here) { + const renamed = await updateFolder(here.id, { name }); + return t("filed.renamed", { name: renamed.name }); + } + const created = await createFolder( + libraryId, + name, + isReal ? folder : null, + ); + return t("filed.created", { name: created.name }); + }, named); + if (done) setForm(null); + } + + async function remove() { + if (!here || !window.confirm(t("filed.confirmDelete", { name: here.name }))) + return; + const parent = here.parent_id; + const done = await attempt(async () => { + const { unfiled } = await deleteFolder(here.id); + return t("filed.deleted", { count: unfiled }); + }); + if (done) { + await navigate({ + to: "/folders", + search: { folder: parent ?? "" }, + }); + } + } + + const link = (target: string) => ({ + to: "/folders" as const, + search: { folder: target }, + }); const controls = ( <> @@ -82,15 +229,47 @@ function DiskFolders({ onClick={() => player.play(songs, 0)} > - {hasNextPage + {morePages ? t("songs.playLoaded", { count: songs.length }) : t("songs.playAll", { count: songs.length })} ) : null} - {mayUploadTo(active) ? ( - + {may && folder !== UNFILED && !missing ? ( + + ) : null} + {may && here ? ( + <> + + + + + ) : null} + {here && mayUploadTo(active) ? ( + - {t("folders.upload")} + {t("filed.uploadHere")} ) : null} @@ -101,11 +280,8 @@ function DiskFolders({ kicker={t("nav.group.library")} title={title} detail={ - first - ? [ - t("folders.count", { count: first.folder_total }), - t("common.tracks", { count: first.track_total }), - ].join(" · ") + tree.data && !listsTracks + ? t("folders.count", { count: folders.length }) : undefined } > @@ -113,26 +289,28 @@ function DiskFolders({
    ); - const breadcrumbs = ( + const crumbs = (