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}
-