feat(files): the Files page — the library in one aligned list, its folders in a rail
Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
This commit is contained in:
@@ -60,6 +60,8 @@ export {
|
||||
} from "./api/plex";
|
||||
import { downloadsApi } from "./api/downloads";
|
||||
export type * from "./api/downloads";
|
||||
import { filesApi } from "./api/files";
|
||||
export type * from "./api/files";
|
||||
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
|
||||
export type * from "./api/diagnostics";
|
||||
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
|
||||
@@ -80,6 +82,7 @@ export const api = {
|
||||
...adminApi,
|
||||
...plexApi,
|
||||
...downloadsApi,
|
||||
...filesApi,
|
||||
...diagnosticsApi,
|
||||
...debugSessionsApi,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("../activeAccount", () => ({
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
import { downloadsApi } from "./downloads";
|
||||
import { filesApi } from "./files";
|
||||
import {
|
||||
beacon,
|
||||
req,
|
||||
@@ -48,10 +49,13 @@ class FakeXHR {
|
||||
onabort: (() => void) | null = null;
|
||||
sent = false;
|
||||
body: unknown = null;
|
||||
url = "";
|
||||
constructor() {
|
||||
FakeXHR.last = this;
|
||||
}
|
||||
open() {}
|
||||
open(_method: string, url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
setRequestHeader(k: string, v: string) {
|
||||
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
|
||||
// overwriting — so a plain assignment here would hide exactly the duplicate-header bug the
|
||||
@@ -470,10 +474,10 @@ describe("the debug session a tab records into", () => {
|
||||
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
|
||||
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
|
||||
// backend's own test only sees the shape the client actually sends.
|
||||
describe("uploadDownload's wire shape", () => {
|
||||
describe("uploadFile's wire shape", () => {
|
||||
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
|
||||
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
|
||||
void downloadsApi.uploadDownload(file, { displayName: "Nyaralás 2026" });
|
||||
void filesApi.uploadFile(file, { displayName: "Nyaralás 2026" });
|
||||
const x = FakeXHR.last;
|
||||
expect(x.body).toBe(file);
|
||||
expect(x.body).not.toBeInstanceOf(FormData);
|
||||
@@ -484,8 +488,15 @@ describe("uploadDownload's wire shape", () => {
|
||||
});
|
||||
|
||||
it("omits the display-name header when there is no display name", () => {
|
||||
void downloadsApi.uploadDownload(new File(["abc"], "clip.mp4"));
|
||||
void filesApi.uploadFile(new File(["abc"], "clip.mp4"));
|
||||
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
|
||||
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the folder it goes into, when it has one", () => {
|
||||
void filesApi.uploadFile(new File(["abc"], "clip.mp4"), { folderId: 7 });
|
||||
expect(FakeXHR.last.headers["X-Upload-Folder"]).toBe("7");
|
||||
expect(FakeXHR.last.url).toBe("/api/files/upload");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
|
||||
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
|
||||
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
|
||||
import { beacon, req, uploadReq, getActiveAccount } from "./core";
|
||||
import { beacon, req, getActiveAccount } from "./core";
|
||||
|
||||
export interface DownloadSpec {
|
||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||
@@ -105,7 +105,6 @@ export interface DownloadJob {
|
||||
// An edit's recipe, or a clip's spec (its request without the name) — by job_kind.
|
||||
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
|
||||
can_download: boolean;
|
||||
expired: boolean;
|
||||
// The Files library (backend downloads/folders): its folder, whether the user keeps it past the
|
||||
// folder's retention, and when it goes (null = never).
|
||||
folder_id: number | null;
|
||||
@@ -142,20 +141,6 @@ export interface ShareLink {
|
||||
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
|
||||
export const DISPLAY_NAME_MAX = 255;
|
||||
|
||||
export interface DownloadUsage {
|
||||
footprint_bytes: number;
|
||||
active_jobs: number;
|
||||
running_jobs: number;
|
||||
max_bytes: number;
|
||||
max_concurrent: number;
|
||||
max_jobs: number;
|
||||
unlimited: boolean;
|
||||
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
|
||||
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
|
||||
upload_max_bytes: number;
|
||||
}
|
||||
|
||||
export interface AdminStorage {
|
||||
files_on_disk: number;
|
||||
total_bytes: number;
|
||||
@@ -214,29 +199,6 @@ export const downloadsApi = {
|
||||
spec?: DownloadSpec;
|
||||
display_name?: string;
|
||||
}): Promise<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
// Ingest a local file into the library. Sent via XHR rather than `req` so the dialog can
|
||||
// show upload progress; any file type is accepted (media gets the full function set, a generic
|
||||
// file is download-only). Returns the job the ingest creates: `done`, or `queued` while the
|
||||
// worker converts media no phone could play as it is (`downloads/upload.py`).
|
||||
// The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
|
||||
// a DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency
|
||||
// runs — so with the old shape an unauthenticated caller could make the server write the whole
|
||||
// per-file allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`;
|
||||
// the server percent-decodes both. Content-Type is left to the browser (the File's own type).
|
||||
uploadDownload: (
|
||||
file: File,
|
||||
opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<DownloadJob> => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
|
||||
};
|
||||
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
|
||||
return uploadReq<DownloadJob>("/api/downloads/upload", file, {
|
||||
onProgress: opts.onProgress,
|
||||
signal: opts.signal,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
enqueueEdit: (body: {
|
||||
source_job_id: number;
|
||||
edit_spec: EditSpec;
|
||||
@@ -250,11 +212,6 @@ export const downloadsApi = {
|
||||
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||
req(`/api/downloads/${id}/storyboard`),
|
||||
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
||||
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
|
||||
// Remove a shared-with-me item from your list (deletes only your share grant, not the file).
|
||||
removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
|
||||
req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
|
||||
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
|
||||
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
|
||||
pauseDownload: (id: number): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
|
||||
@@ -266,18 +223,6 @@ export const downloadsApi = {
|
||||
// deletes the old file, and rebuilds it under the current format/naming rules.
|
||||
redownloadDownload: (id: number): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
|
||||
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
|
||||
// Update a download's display metadata (title / channel name+link / extra reference URLs).
|
||||
updateDownloadMeta: (
|
||||
id: number,
|
||||
meta: {
|
||||
display_name?: string;
|
||||
display_uploader?: string;
|
||||
display_uploader_url?: string;
|
||||
extra_links?: string[];
|
||||
},
|
||||
): Promise<DownloadJob> =>
|
||||
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
|
||||
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
|
||||
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
||||
unshareDownload: (id: number, email: string) =>
|
||||
@@ -330,14 +275,6 @@ export const downloadsApi = {
|
||||
},
|
||||
onSettled,
|
||||
),
|
||||
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
|
||||
// account via ?account= (the server honours it only for an account in this browser's wallet) —
|
||||
// otherwise the download resolves to the session-default account and 404s for a per-tab
|
||||
// account's file.
|
||||
downloadFileUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
|
||||
},
|
||||
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
|
||||
storyboardImageUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
|
||||
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
|
||||
["adminApi", () => import("./admin")],
|
||||
["plexApi", () => import("./plex")],
|
||||
["downloadsApi", () => import("./downloads")],
|
||||
["filesApi", () => import("./files")],
|
||||
["diagnosticsApi", () => import("./diagnostics")],
|
||||
["debugSessionsApi", () => import("./diagnostics")],
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// The Files library: a user's done files, the folders they sit in, and the file itself
|
||||
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
|
||||
import { getActiveAccount, req, uploadReq } from "./core";
|
||||
import type { DownloadJob } from "./downloads";
|
||||
|
||||
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
|
||||
export type FolderKind = "source" | "own";
|
||||
|
||||
export interface FileFolder {
|
||||
id: number;
|
||||
kind: FolderKind;
|
||||
// A source folder's origin — `youtube`, `plex`, `upload`, `site:<host>` — which the client names
|
||||
// in the reader's language; null on an own folder.
|
||||
source_key: string | null;
|
||||
name: string | null; // an own folder's name; null on a source folder
|
||||
retention_days: number | null; // null = kept until deleted
|
||||
count: number; // its done files
|
||||
size_bytes: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export type FileSort = "added" | "name" | "size";
|
||||
export type FileOrder = "asc" | "desc";
|
||||
|
||||
export interface FileListParams {
|
||||
folderId?: number | null; // one folder's files; absent = every file
|
||||
q?: string;
|
||||
sort?: FileSort;
|
||||
order?: FileOrder;
|
||||
shared?: boolean; // the files others shared with me instead of mine
|
||||
}
|
||||
|
||||
export interface LibraryFile extends DownloadJob {
|
||||
// When it was shared with me (the "added" of a shared file); null on my own.
|
||||
shared_at?: string | null;
|
||||
// Where it came from — its source folder's key (`youtube`, `site:<host>`, …), wherever it sits now.
|
||||
source_key: string;
|
||||
}
|
||||
|
||||
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
|
||||
export interface FilePatch {
|
||||
name?: string | null;
|
||||
uploader?: string | null;
|
||||
uploader_url?: string | null;
|
||||
extra_links?: string[];
|
||||
folder_id?: number;
|
||||
kept?: boolean;
|
||||
}
|
||||
|
||||
export interface StorageUsage {
|
||||
footprint_bytes: number;
|
||||
active_jobs: number;
|
||||
running_jobs: number;
|
||||
max_bytes: number;
|
||||
max_concurrent: number;
|
||||
max_jobs: number;
|
||||
unlimited: boolean;
|
||||
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
|
||||
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
|
||||
upload_max_bytes: number;
|
||||
}
|
||||
|
||||
function listQuery(p: FileListParams): string {
|
||||
const q = new URLSearchParams();
|
||||
if (p.shared) q.set("scope", "shared");
|
||||
else if (p.folderId != null) q.set("folder_id", String(p.folderId));
|
||||
if (p.q?.trim()) q.set("q", p.q.trim());
|
||||
if (p.sort) q.set("sort", p.sort);
|
||||
if (p.order) q.set("order", p.order);
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
|
||||
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
|
||||
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
|
||||
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
|
||||
req(`/api/files/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteFile: (id: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`/api/files/${id}`, { method: "DELETE" }),
|
||||
// Drop a file someone shared with me from my list (only my grant goes, not their file).
|
||||
removeSharedFile: (id: number): Promise<{ ok: true; removed: number }> =>
|
||||
req(`/api/files/shared/${id}`, { method: "DELETE" }),
|
||||
// Put a local file in the library. Sent via XHR rather than `req` so the dialog can show upload
|
||||
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||
// download-only). Returns the job the ingest creates: `done`, or `queued` while the worker
|
||||
// converts media no phone could play as it is (`downloads/upload.py`).
|
||||
// The body IS the file, and the names ride as headers. Not a style choice: a multipart part is a
|
||||
// DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency runs
|
||||
// — so with that shape an unauthenticated caller could make the server write the whole per-file
|
||||
// allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`; the server
|
||||
// percent-decodes them. Content-Type is left to the browser (the File's own type). No folder =
|
||||
// the Uploads folder.
|
||||
uploadFile: (
|
||||
file: File,
|
||||
opts: {
|
||||
displayName?: string;
|
||||
folderId?: number | null;
|
||||
onProgress?: (pct: number) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<DownloadJob> => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
|
||||
};
|
||||
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
|
||||
if (opts.folderId != null) headers["X-Upload-Folder"] = String(opts.folderId);
|
||||
return uploadReq<DownloadJob>("/api/files/upload", file, {
|
||||
onProgress: opts.onProgress,
|
||||
signal: opts.signal,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
|
||||
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
|
||||
// file resolves to the session-default account and 404s for a per-tab account's file.
|
||||
fileUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
|
||||
},
|
||||
};
|
||||
@@ -5,12 +5,15 @@ import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// The download queue, the per-card download index, and the storage-usage meter all move together
|
||||
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
|
||||
// The download queue, the per-card download index, the library (its listings and folder counts) and
|
||||
// the storage meter all move together whenever a job is enqueued, deleted, finishes, or a file is
|
||||
// renamed or moved — invalidate them as one unit.
|
||||
export function invalidateDownloads(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
|
||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||
qc.invalidateQueries({ queryKey: qk.files() });
|
||||
qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
qc.invalidateQueries({ queryKey: qk.storageUsage() });
|
||||
}
|
||||
|
||||
// One-click share: the row's Share button turns "share this" into a single action.
|
||||
|
||||
@@ -117,6 +117,7 @@ export const SHORTCUTS = [
|
||||
labelKey: "keys.goNotifications",
|
||||
page: "notifications",
|
||||
},
|
||||
{ id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" },
|
||||
{
|
||||
id: "goDownloads",
|
||||
scope: "page",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ListVideo,
|
||||
ScrollText,
|
||||
FileSearch,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
@@ -88,6 +89,12 @@ const MODULES: readonly ModuleDef[] = [
|
||||
icon: Bell,
|
||||
hasBadge: true,
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
labelKey: "files.navLabel",
|
||||
railKey: "nav.rail.files",
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
id: "downloads",
|
||||
labelKey: "downloads.navLabel",
|
||||
|
||||
@@ -11,6 +11,7 @@ const EXPECTED: Record<Page, string> = {
|
||||
channels: "header.channelManager",
|
||||
playlists: "header.account.playlists",
|
||||
notifications: "inbox.navLabel",
|
||||
files: "files.navLabel",
|
||||
downloads: "downloads.navLabel",
|
||||
stats: "header.usageStats",
|
||||
plex: "plex.navLabel",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Per-user customization of a side panel's group cards: their order, which are collapsed, and
|
||||
// which are hidden — the same "customize" affordance the feed filter rail had, generalized so
|
||||
// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and
|
||||
// every panel (feed / plex / playlists / files) has its own saved layout. Persisted to localStorage and
|
||||
// the server-side `preferences` blob (schemaless), so it follows the account across devices.
|
||||
import { LS, readAccount, writeAccount } from "./storage";
|
||||
|
||||
export type PanelId = "feed" | "plex" | "playlists";
|
||||
export type PanelId = "feed" | "plex" | "playlists" | "files";
|
||||
|
||||
export interface PanelLayout {
|
||||
order: string[];
|
||||
@@ -30,6 +30,7 @@ const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||
"contentrating",
|
||||
],
|
||||
playlists: ["manage", "list"],
|
||||
files: ["library", "sources", "own"],
|
||||
};
|
||||
|
||||
// localStorage key per panel (feed keeps its original key for backward compatibility).
|
||||
@@ -37,13 +38,18 @@ const LS_KEY: Record<PanelId, string> = {
|
||||
feed: LS.sidebarLayout,
|
||||
plex: LS.plexLayout,
|
||||
playlists: LS.playlistsLayout,
|
||||
files: LS.filesLayout,
|
||||
};
|
||||
|
||||
// Key on the server `preferences` blob per panel (feed keeps its original key).
|
||||
export const PREF_KEY: Record<PanelId, "sidebarLayout" | "plexLayout" | "playlistsLayout"> = {
|
||||
export const PREF_KEY: Record<
|
||||
PanelId,
|
||||
"sidebarLayout" | "plexLayout" | "playlistsLayout" | "filesLayout"
|
||||
> = {
|
||||
feed: "sidebarLayout",
|
||||
plex: "plexLayout",
|
||||
playlists: "playlistsLayout",
|
||||
files: "filesLayout",
|
||||
};
|
||||
|
||||
export function defaultLayout(panel: PanelId): PanelLayout {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FeedFilters } from "./api";
|
||||
import type { FeedFilters, FileListParams } from "./api";
|
||||
|
||||
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
|
||||
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
|
||||
@@ -64,11 +64,14 @@ export const qk = {
|
||||
// --- sharing ---
|
||||
shareRecipients: () => ["share-recipients"] as const,
|
||||
|
||||
// --- files (the library) ---
|
||||
// Every listing's key starts with "files", so one invalidation reaches each folder, search and sort.
|
||||
files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)),
|
||||
fileFolders: () => ["file-folders"] as const,
|
||||
storageUsage: () => ["storage-usage"] as const,
|
||||
// --- downloads ---
|
||||
downloads: () => ["downloads"] as const,
|
||||
downloadsShared: () => ["downloads-shared"] as const,
|
||||
downloadIndex: () => ["download-index"] as const,
|
||||
downloadUsage: () => ["download-usage"] as const,
|
||||
downloadProfiles: () => ["download-profiles"] as const,
|
||||
downloadCredentials: () => ["download-credentials"] as const,
|
||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||
|
||||
@@ -10,6 +10,12 @@ export const LS = {
|
||||
plexLayout: "siftlode.plexLayout",
|
||||
playlistsLayout: "siftlode.playlistsLayout",
|
||||
playlistsCollapsed: "siftlode.playlistsCollapsed",
|
||||
filesLayout: "siftlode.filesLayout",
|
||||
filesCollapsed: "siftlode.filesCollapsed",
|
||||
// The Files view last open (every file, shared with me, or a folder id), per-account.
|
||||
filesView: "siftlode.filesView",
|
||||
// The Files table's sort and rows-per-page (DataTable's persist blob).
|
||||
filesTable: "siftlode.filesTable",
|
||||
hints: "siftlode.hints",
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
|
||||
@@ -131,6 +131,7 @@ const PAGES = [
|
||||
"audit",
|
||||
"logs",
|
||||
"notifications",
|
||||
"files",
|
||||
"downloads",
|
||||
"plex",
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user