fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic

Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
This commit is contained in:
2026-10-23 13:45:40 +02:00
parent ad05932217
commit b443ac0c84
51 changed files with 561 additions and 290 deletions
@@ -1,129 +0,0 @@
// @vitest-environment jsdom
//
// Container-logic contract of the tag-suggestion hook, the wiring the presentational
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
// optimistic cache write is the actual code path (not a re-implementation).
import { describe, it, expect, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
});
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
const confirmState = vi.hoisted(() => ({ result: true }));
vi.mock("../../components/ui/confirmContext", () => ({
useConfirm: () => () => Promise.resolve(confirmState.result),
}));
import { api, type Tag } from "../../lib/api";
import type { TagSuggestions } from "../../lib/api/tags";
import { qk } from "../../lib/queryKeys";
import { useTagSuggestions } from "./useTagSuggestions";
const tag = (id: number, name: string): Tag => ({
id,
name,
color: null,
category: "other",
system: false,
channel_count: 0,
});
const docu = tag(1, "docu");
const history = tag(2, "history");
const tags = [docu, history];
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
const seed = (): TagSuggestions => ({
UC_a: [
{ tag_id: docu.id, confidence: 0.95 },
{ tag_id: history.id, confidence: 0.6 },
],
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
});
function setup() {
const qc = new QueryClient({
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
});
qc.setQueryData(qk.tagSuggestions(), seed());
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
const toggleTag = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
return { qc, toggleTag, result };
}
describe("useTagSuggestions", () => {
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
const { result } = setup();
const forA = result.current.suggestionsFor("UC_a");
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
});
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
const { result } = setup();
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
expect(result.current.visibleSuggestions).toHaveLength(3);
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
expect(result.current.highConfidence).toEqual([
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
]);
});
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
const { qc, toggleTag, result } = setup();
act(() => result.current.acceptSuggestion("UC_a", docu.id));
// The real tag is attached (hasTag=false → attach).
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
// OTHER ghost chip (history) goes too — not just the accepted one.
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
expect(cache?.UC_a).toBeUndefined();
expect(cache?.UC_b).toBeDefined(); // other channels untouched
});
it("dismiss hides only that one (channel, tag) for the session", () => {
const { result } = setup();
act(() => result.current.dismissSuggestion("UC_a", history.id));
// UC_a loses only the history suggestion; docu stays.
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
});
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
confirmState.result = true;
const { toggleTag, result } = setup();
await act(async () => {
await result.current.acceptAllHighConfidence();
});
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
expect(toggleTag).toHaveBeenCalledTimes(1);
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
});
it("bulk-accept does nothing when the confirm is cancelled", async () => {
confirmState.result = false;
const { toggleTag, result } = setup();
await act(async () => {
await result.current.acceptAllHighConfidence();
});
expect(toggleTag).not.toHaveBeenCalled();
});
});
@@ -1,91 +0,0 @@
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Tag, type TagSuggestions } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { useConfirm } from "../../components/ui/confirmContext";
import type { SuggestedTag } from "./ChannelTags";
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
const HIGH_CONFIDENCE = 0.9;
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
/** Suggested-tag state for the Channel Manager: the /tags/suggestions query, a session-only
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
* the large Channels component. */
export function useTagSuggestions({
tags,
toggleTag,
}: {
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
tags: Tag[];
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
* failed accept restores the ghost chips this hook optimistically removed. */
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
const suggestionsFor = (cid: string): SuggestedTag[] =>
(suggestions[cid] ?? []).flatMap((s) => {
const tag = tagById.get(s.tag_id);
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
});
const dismissSuggestion = (cid: string, tagId: number) =>
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
const acceptSuggestion = (cid: string, tagId: number) => {
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
if (!old?.[cid]) return old;
const next = { ...old };
delete next[cid];
return next;
});
toggleTag(cid, tagId, false);
};
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
// the bulk action accepts.
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
);
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
const acceptAllHighConfidence = async () => {
const ok = await confirm({
title: t("channels.tags.acceptAllTitle"),
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
});
if (!ok) return;
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
};
return {
suggestionsFor,
dismissSuggestion,
acceptSuggestion,
visibleSuggestions,
highConfidence,
acceptAllHighConfidence,
};
}
@@ -37,7 +37,8 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { sourceLabel, useFilesActions } from "../../modules/files";
import { useFilesActions } from "../../modules/files";
import { sourceLabel } from "../folders/fileFolders";
import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { setCornerLift } from "../../components/ui/backToTopState";
@@ -28,7 +28,7 @@ import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { useSavedViews, asFilters } from "./savedViewsContext";
import { useActiveFilters } from "../../lib/useActiveFilters";
import { buildPlexActiveFilters } from "../../modules/plex";
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
import { useUndoable } from "../../lib/useUndoable";
import UndoToolbar from "../../components/ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
@@ -1,102 +0,0 @@
// @vitest-environment jsdom
//
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
// second re-fit needs no user action at all.
//
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
}),
};
});
const fit = { visible: 1 };
vi.mock("../../lib/useChipOverflowFit", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/useChipOverflowFit")>();
return { ...actual, useChipOverflowFit: () => fit.visible };
});
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
cleanup();
fit.visible = 1;
});
describe("TagFilterChips — the overflow menu", () => {
it("does not come back on its own once the lane stops overflowing", () => {
const { rerender } = render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
// nothing is collapsed any more: both trigger and menu go.
fit.visible = 3;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toBeNull();
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
// returning trigger must not claim to be expanded.
fit.visible = 1;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("does not come back when the chip set SHRINKS under the open menu", () => {
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
// unclamped never fires, and the flag survives the one window it exists to close.
//
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
fit.visible = 3;
const { rerender } = render(<TagFilterChips chips={five} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
expect(menu()).toBeNull();
// …and now the layout effect lands: two long names, only one of them fits.
fit.visible = 1;
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("still opens and closes on the trigger", () => {
render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
fireEvent.click(moreBtn()!);
expect(menu()).toBeNull();
});
});
@@ -1,147 +0,0 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { Chip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { SURFACE } from "../../components/ui/surfaces";
import { ChipLabelPending } from "../../components/ui/Skeleton";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
export interface FilterChip {
key: string;
label: string;
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
pending?: boolean;
active: boolean;
onToggle: () => void;
}
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
function ChipText({ chip }: { chip: FilterChip }) {
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
}
function TagChip({ chip }: { chip: FilterChip }) {
return (
<Chip onClick={chip.onToggle} pressed={chip.active}>
<ChipText chip={chip} />
</Chip>
);
}
export default function TagFilterChips({
chips,
className,
}: {
chips: FilterChip[];
className?: string;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
const key = chips
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
.join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: chips.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, chips.length - visible);
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
//
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
// flag is reset in RENDER against the same condition that hides it: convergent (the write
// falsifies it) and, unlike an effect, gone before the frame is painted.
const [openRaw, setOpen] = useState(false);
if (openRaw && hidden === 0) setOpen(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
const { setReference } = pop.refs;
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
read each chip's right edge, the rem gap and the pill width off real boxes. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
>
{chips.map((c, i) => (
<span
key={c.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<span className={chipClass()}>
<ChipText chip={c} />
</span>
</span>
))}
<span ref={pillRef} className={chipClass()}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
{hidden > 0 && (
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
+{hidden}
</Chip>
)}
</div>
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
>
{chips.slice(visible).map((c) => (
<TagChip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
);
}
@@ -0,0 +1,44 @@
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import type { FileFolder } from "../../lib/api";
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
// its name to the client, in the reader's language; an own folder carries the name its user gave it;
// the Hub folder (one per user, made by a hub token) is named by the client too.
const SITE_PREFIX = "site:";
type T = (key: string) => string;
/** The name of a source: a site is its host, the rest are words of the UI. */
export function sourceLabel(key: string, t: T): string {
if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
if (key === "youtube" || key === "plex" || key === "upload") return t(`files.source.${key}`);
return key;
}
export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
if (key === "youtube") return YouTubeIcon;
if (key === "plex") return Film;
if (key === "upload") return Upload;
return Globe;
}
type Folderish = Pick<FileFolder, "kind" | "source_key" | "name">;
export function folderLabel(folder: Folderish, t: T): string {
if (folder.kind === "hub") return t("files.folder.hub");
return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
}
export function folderIcon(folder: Folderish): LucideIcon | typeof YouTubeIcon {
if (folder.kind === "hub") return Server;
return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
}
/** Whether an upload may land in `folder` (the backend's `folders.upload_target`): an own folder,
* the Hub folder, or Uploads — a file put in a site's folder would claim an origin it does not have. */
export function takesUploads(folder: Folderish): boolean {
return folder.kind !== "source" || folder.source_key === "upload";
}
@@ -13,7 +13,7 @@ import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ModuleStores } from "./ModuleStores";
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
import { useFeedFilters, useFeedSavedViews, useFeedView } from "../../modules/feed";
import { useFeedFilters, useFeedSavedViews } from "../../modules/feed";
import { usePlex, usePlexSavedViews } from "../../modules/plex";
import { useChannelsActions } from "../../modules/channels";
import { useFilesActions } from "../../modules/files";
@@ -29,7 +29,6 @@ const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
// Each store's read side, as the app's readers reach it (the module faces).
function Readers() {
useFeedFilters();
useFeedView();
useFeedSavedViews();
usePlex();
usePlexSavedViews();
@@ -1,275 +0,0 @@
// @vitest-environment jsdom
//
// A split export that fails part-way. Every clip is its own job,
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify,
}));
import VideoEditor from "./VideoEditor";
import { api, HttpError, type DownloadJob } from "../../lib/api";
import { markReported } from "../../lib/api/core";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
notify.mockReset();
});
const job = {
id: 7,
display_name: "Talk",
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
} as unknown as DownloadJob;
describe("VideoEditor — a split export that fails part-way", () => {
it("says how many clips made it, and a retry queues only the rest", async () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
let refuse: (e: unknown) => void = () => {};
const enqueue = vi
.spyOn(api, "enqueueEdit")
.mockResolvedValueOnce({} as never)
.mockImplementationOnce(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
)
.mockResolvedValue({} as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
// The playhead is where the video element says it is.
const video = document.querySelector("video")!;
const playhead = (s: number) => {
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
fireEvent.timeUpdate(video);
};
playhead(50);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
// While the clips are queued the cut list holds still: a split now would be a range never queued.
playhead(75);
fireEvent.click(screen.getByText("editor.splitHere"));
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
const refused = new HttpError(422, "errors.generic", { code: "quota" });
markReported(refused);
await act(async () => refuse(refused));
await waitFor(() =>
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
),
);
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
// the second of the same two.
fireEvent.click(screen.getByText("editor.create"));
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
});
});
// A time typed into the selected segment's field belongs to that segment: selecting another one
// drops it, and never commits it to the new selection's cut.
describe("VideoEditor — a typed time and another segment", () => {
it("does not move the newly selected segment's cut with a time typed for the old one", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
for (const at of [30, 60]) {
Object.defineProperty(video, "currentTime", {
value: at,
configurable: true,
writable: true,
});
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
}
const segs = () => screen.getAllByTitle("editor.keptSeg");
fireEvent.pointerDown(segs()[1]!);
const inField = screen.getByRole("textbox", { name: "editor.in" });
act(() => inField.focus());
fireEvent.change(inField, { target: { value: "0:40" } });
// Another segment is selected before the field loses the focus (a press on it).
fireEvent.pointerDown(segs()[2]!);
fireEvent.blur(inField);
const cuts = screen
.getAllByRole("slider", { name: "editor.cutN" })
.map((c) => c.getAttribute("aria-valuenow"));
expect(cuts).toEqual(["30", "60"]);
});
});
// An unreadable time is said on a line under the segment's row: outside the field's label, so the
// field's name stays "Out" and the line is read once, as its description.
describe("VideoEditor — an unreadable time", () => {
it("is said under the row, outside the field's label", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
fireEvent.pointerDown(screen.getAllByTitle("editor.keptSeg")[0]!);
const out = screen.getByRole("textbox", { name: "editor.out" });
fireEvent.change(out, { target: { value: "abc" } });
fireEvent.keyDown(out, { key: "Enter" });
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalid");
expect(line.closest("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "editor.out" })).toBe(out);
expect(out.getAttribute("aria-describedby")).toBe(line.id);
});
});
// The cuts sit on the shared time track: each is a slider the keyboard steps, and a step stops a
// tenth short of the next cut, so no segment ever closes up.
describe("VideoEditor — a cut on the keyboard", () => {
it("steps a focused cut with the arrows and stops it before its neighbours", () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(cut.getAttribute("aria-valuenow")).toBe("50");
fireEvent.keyDown(cut, { key: "ArrowRight", shiftKey: true });
expect(cut.getAttribute("aria-valuenow")).toBe("51");
fireEvent.keyDown(cut, { key: "End" });
expect(cut.getAttribute("aria-valuenow")).toBe("99.9");
});
});
// A cut's remove button and the keep toggle of the segment after it: the button is a button a click
// or a key works (it sat inside the cut's slider, pointer-only, its arrows moving the cut), and it
// sits at the cut's foot, clear of the toggle at the segment's head; the toggle sits over the cut's
// hit area that reaches into its segment, so a press on it is the toggle's.
describe("VideoEditor — a cut's remove button", () => {
const split = () => {
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
render(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={job} onClose={() => {}} />
</QueryClientProvider>,
);
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 50, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
};
it("removes the cut on a click (Enter on a focused button is one), and is not part of the slider", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
expect(screen.getByRole("slider", { name: "editor.cutN" }).contains(remove)).toBe(false);
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.queryByRole("slider", { name: "editor.cutN" })).toBeNull();
});
it("hands the focus on to a cut beside it, or to Split here, never to <body>", async () => {
split();
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
fireEvent.click(screen.getByText("editor.splitHere"));
const [second] = screen.getAllByRole("button", { name: "editor.removeCut" }).slice(1);
act(() => second!.focus());
fireEvent.click(second!);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("slider", { name: "editor.cutN" })),
);
const last = screen.getByRole("button", { name: "editor.removeCut" });
act(() => last.focus());
fireEvent.click(last);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByText("editor.splitHere").closest("button")),
);
});
it("a remove refused while the clips are queued leaves no focus to take later", async () => {
let refuse: (e: unknown) => void = () => {};
vi.spyOn(api, "enqueueEdit").mockImplementation(
() =>
new Promise((_, reject) => {
refuse = reject;
}) as never,
);
split();
fireEvent.click(screen.getByText("editor.createN"));
await waitFor(() => expect(api.enqueueEdit).toHaveBeenCalled());
// The cut list holds still while the clips are queued: the remove is refused.
const remove = screen.getByRole("button", { name: "editor.removeCut" });
act(() => remove.focus());
fireEvent.click(remove);
expect(screen.getByRole("slider", { name: "editor.cutN" })).toBeTruthy();
await act(async () => refuse(new Error("refused")));
// The create failed and the editor stays; a later split keeps the focus where it was.
const video = document.querySelector("video")!;
Object.defineProperty(video, "currentTime", { value: 70, configurable: true, writable: true });
fireEvent.timeUpdate(video);
const splitButton = screen.getByText("editor.splitHere").closest("button")!;
act(() => splitButton.focus());
fireEvent.click(splitButton);
expect(screen.getAllByRole("slider", { name: "editor.cutN" })).toHaveLength(2);
expect(document.activeElement).toBe(splitButton);
});
it("sits under the track at its cut, off the cut's grab area; the keep toggle sits over it", () => {
split();
const remove = screen.getByRole("button", { name: "editor.removeCut" });
const track = screen.getByRole("group", { name: "editor.timeline" });
// A press anywhere on the cut drags it; the X that deletes it (no undo) is below the track.
expect(track.contains(remove)).toBe(false);
const cut = screen.getByRole("slider", { name: "editor.cutN" });
expect(remove.closest<HTMLElement>("[style]")?.style.left).toBe(cut.style.left);
const toggles = within(track).getAllByRole("button", { pressed: true });
for (const toggle of toggles) expect(toggle.className).toMatch(/\bz-menu\b/);
});
});
@@ -1,638 +0,0 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { invalidateDownloads } from "../../lib/downloads";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
import clsx from "clsx";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame";
import { Button } from "../../components/ui/Button";
import { failureReason, notifyActionError } from "../../lib/actionErrors";
import { markReported } from "../../lib/api/core";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
import { useTimecodeField } from "../../components/ui/useTimecodeField";
import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode";
type Seg = { id: number; start: number; end: number; keep: boolean };
const MIN_SEG = 0.1;
/** Queues each clip as its own job, in order; `done` collects the ids that made it, so the caller's
* catch can say how many did when one fails part-way. */
async function enqueueClips(
jobId: number,
clips: { id: number; spec: EditSpec; dn: string | undefined }[],
done: number[],
) {
for (const c of clips) {
await api.enqueueEdit({ source_job_id: jobId, edit_spec: c.spec, display_name: c.dn });
done.push(c.id);
}
}
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const splitRef = useRef<HTMLButtonElement>(null);
const sid = useRef(1);
const nid = () => sid.current++;
const srcDur = job.asset?.duration_s ?? 0;
const [duration, setDuration] = useState(srcDur);
const [current, setCurrent] = useState(0);
const [playing, setPlaying] = useState(false);
const [trackW, setTrackW] = useState(0);
// Intrinsic (source) video dims: the crop frame's picture shape, and the crop's source pixels.
// From the asset's metadata, so the frame sizes before the media decodes.
const [vdim, setVdim] = useState({ w: job.asset?.width ?? 0, h: job.asset?.height ?? 0 });
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
// Create started with, so the cut list holds still until it is done: a split or a merge in between
// would deselect a range that was never queued, or keep one that was.
const creating = useRef(false);
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
const series = useRef<Map<number, string> | null>(null);
const [selId, setSelId] = useState(0);
const [output, setOutput] = useState<"separate" | "join">("separate");
const [cropOn, setCropOn] = useState(false);
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
const [accurate, setAccurate] = useState(true);
const [name, setName] = useState("");
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
const tc = (sec: number) => formatTimecode(sec, duration >= 3600);
const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
const sb = useQuery({
queryKey: qk.storyboard(job.id),
queryFn: () => api.downloadStoryboard(job.id),
staleTime: Infinity,
retry: false,
});
const strip = sb.data?.available ? sb.data : null;
const defaultName = job.display_name || job.asset?.title || "";
// Measure the track for aspect-correct filmstrip tiling.
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setTrackW(el.clientWidth));
ro.observe(el);
setTrackW(el.clientWidth);
return () => ro.disconnect();
}, []);
// The picture's shape, which the crop frame is drawn on (it finds the picture inside the video's
// box itself: max-h can pillarbox it).
const va = vdim.w && vdim.h ? vdim.w / vdim.h : aspect;
const onLoaded = () => {
const v = videoRef.current;
if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d);
if (v.videoWidth && v.videoHeight) setVdim({ w: v.videoWidth, h: v.videoHeight });
// Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
// the no-op guard (isFullSingle: end >= duration) reads false and enables a "Create" that files a
// clip of an unmodified video and drops its tail. Only touches the untouched segment (end===srcDur).
setSegments((s) => (s.length === 1 && s[0]!.end === srcDur ? [{ ...s[0]!, end: d }] : s));
};
const onTimeUpdate = () => {
const v = videoRef.current;
if (v) setCurrent(v.currentTime);
};
const seek = useCallback(
(s: number) => {
const v = videoRef.current;
if (v) v.currentTime = Math.max(0, Math.min(duration, s));
},
[duration],
);
const playPause = () => {
const v = videoRef.current;
if (!v) return;
// play() rejects when a pause or a source change interrupts it; the button state follows the
// element's own events, so there is nothing to undo.
if (v.paused) v.play().catch(() => {});
else v.pause();
};
// --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
// A cut removed by its button takes the button with it: once it is gone, the focus moves on to the
// cut before it (or the one now first), else to Split here — not to <body>, out of the dialog.
const refocusAfterRemove = useRef<number | null>(null);
useEffect(() => {
const bi = refocusAfterRemove.current;
if (bi == null) return;
refocusAfterRemove.current = null;
const cut = trackRef.current?.querySelector<HTMLElement>(
`[data-handle="cut-${Math.max(0, bi - 1)}"]`,
);
(cut ?? splitRef.current)?.focus();
}, [boundaries.length]);
const splitAtPlayhead = () => {
if (creating.current) return;
const time = current;
setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
if (i < 0) return segs;
const s = segs[i]!; // i >= 0 from findIndex, so segs[i] exists
const b: Seg = { id: nid(), start: time, end: s.end, keep: s.keep };
const next = [...segs.slice(0, i), { ...s, end: time }, b, ...segs.slice(i + 1)];
return next;
});
};
const moveBoundary = useCallback(
(bi: number, time: number) =>
setSegments((segs) => {
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - MIN_SEG;
const tt = Math.max(lo, Math.min(hi, time));
const copy = [...segs];
copy[bi] = { ...copy[bi]!, end: tt };
copy[bi + 1] = { ...copy[bi + 1]!, start: tt };
return copy;
}),
[],
);
const deleteBoundary = (bi: number) =>
setSegments((segs) => {
if (creating.current || segs.length < 2) return segs;
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
});
const toggleKeep = (id: number) =>
setSegments((segs) =>
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
);
// --- timeline hover preview ---
// The hover point, measured in the track's pointer handler (track-relative x and its time), so
// render never reads the track's geometry (a DOM read in render made the React Compiler skip it).
const [hover, setHover] = useState<{ x: number; time: number } | null>(null);
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
const tileStyle = (idx: number): React.CSSProperties => {
const cols = strip?.cols ?? 12;
const rows = strip?.rows ?? 1;
const i = Math.max(0, Math.min((strip?.count ?? 1) - 1, idx));
const col = i % cols;
const row = Math.floor(i / cols);
return {
backgroundImage: `url(${api.storyboardImageUrl(job.id)})`,
backgroundSize: `${cols * 100}% ${rows * 100}%`,
backgroundPosition: `${cols > 1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`,
};
};
const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0;
const hoverTime = hover?.time ?? null;
// Clamp the hover-scrub popover inside the track so it never overflows the modal (which would
// add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW].
const HOVER_W = 148;
const hoverLeft =
hover != null
? Math.max(0, Math.min(Math.max(0, (trackW || HOVER_W) - HOVER_W), hover.x - HOVER_W / 2))
: 0;
// --- create ---
const kept = segments.filter((s) => s.keep);
const keptDur = kept.reduce((a, s) => a + (s.end - s.start), 0);
const isFullSingle =
segments.length === 1 &&
kept.length === 1 &&
kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const v = videoRef.current;
const vw = v?.videoWidth || vdim.w;
const vh = v?.videoHeight || vdim.h;
if (!cropOn) return undefined;
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
return cropToSourcePx(crop, vw, vh) ?? undefined;
};
const create = useMutation({
mutationFn: async () => {
creating.current = true;
const cp = cropPx();
const base = name.trim() || defaultName;
if (willJoin) {
const spec: EditSpec = {
segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })),
accurate,
};
if (cp) spec.crop = cp;
await api.enqueueEdit({
source_job_id: job.id,
edit_spec: spec,
display_name: name.trim() || undefined,
});
return { files: 1 };
}
const N = kept.length;
const prev = series.current;
const suffix =
prev && kept.every((s) => prev.has(s.id))
? prev
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
series.current = suffix;
// Every clip's request is built before the try, and queued by a helper: the React Compiler
// cannot lower a conditional or a loop inside one yet, and skipped the whole editor for it.
const clips = kept.map((s) => {
const spec: EditSpec = {
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
};
if (cp) spec.crop = cp;
else spec.accurate = accurate;
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
return { id: s.id, spec, dn };
});
const done: number[] = [];
try {
await enqueueClips(job.id, clips, done);
} catch (e) {
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
// so trying again queued the first two a second time. Say how many made it, and deselect them
// so another try adds only the rest.
if (done.length) {
invalidateDownloads(qc);
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
// error dialog and marked the error, so that helper would say nothing — and the count is
// what the dialog does not tell.
notify({
level: "error",
title: t("editor.partlyCreated", { done: done.length, total: N }),
message: failureReason(e),
});
markReported(e);
}
throw e;
}
return { files: N };
},
onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc);
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
onError: (e) => notifyActionError(e, t("editor.failed")),
onSettled: () => {
creating.current = false;
},
});
const sel = segments.find((s) => s.id === selId) ?? segments[0];
const selIdx = segments.findIndex((s) => s.id === sel?.id);
const hours = duration >= 3600;
const inField = useTimecodeField(
sel?.start ?? 0,
hours,
(v) => selIdx > 0 && moveBoundary(selIdx - 1, v),
);
const outField = useTimecodeField(
sel?.end ?? 0,
hours,
(v) => selIdx < segments.length - 1 && moveBoundary(selIdx, v),
);
const timeErrorId = useId();
return (
<Modal
initialFocus="field"
title={t("editor.title", { name: defaultName })}
onClose={onClose}
maxWidth="max-w-3xl"
>
<div className="space-y-3">
{/* video + crop overlay */}
<div className="relative bg-black rounded-lg overflow-hidden select-none">
<video
ref={videoRef}
src={fileUrl}
onLoadedMetadata={onLoaded}
onTimeUpdate={onTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="w-full max-h-[42dvh] mx-auto block object-contain"
playsInline
/>
{cropOn && <CropFrame aspect={va} rect={crop} ratio={null} onChange={setCrop} />}
</div>
{/* transport */}
<div className="flex items-center gap-3">
<IconButton variant="outline" size="md" onClick={playPause} label={t("editor.playPause")}>
{playing ? <Pause /> : <Play />}
</IconButton>
<div className="text-xs text-muted tabular-nums">
{tc(current)} / {tc(duration)}
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
ref={splitRef}
onClick={splitAtPlayhead}
icon={<SplitSquareHorizontal />}
>
{t("editor.splitHere")}
</Button>
</div>
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
<div className="relative">
<div ref={trackRef}>
<RangeTrack
label={t("editor.timeline")}
duration={duration}
playhead={current}
onSeek={(time) => {
setCurrent(time);
seek(time);
}}
onHover={setHover}
className="h-14 rounded-lg bg-surface border border-border cursor-pointer"
background={
stripCells > 0 && (
<div className="absolute inset-0 flex pointer-events-none">
{Array.from({ length: stripCells }).map((_, i) => (
<div
key={i}
className="h-full flex-1"
style={tileStyle(
Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)),
)}
/>
))}
</div>
)
}
regions={segments.map((s) => ({
key: s.id,
start: s.start,
end: s.end,
// segment tint: dropped = dimmed + hatched
className: clsx(
s.end < duration && "border-r border-black/40",
s.keep ? "bg-accent/10" : "bg-black/60",
s.id === selId && "ring-2 ring-inset ring-white/70",
),
style: s.keep
? undefined
: {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
},
title: s.keep ? t("editor.keptSeg") : t("editor.droppedSeg"),
onPointerDown: () => setSelId(s.id),
children: (
// keep/drop toggle, over the cut's hit area beside it (that reaches 12 px into the
// segment): a press on the eye is the eye's, never a drag of the cut
<Pressable
aria-pressed={s.keep}
onPointerDown={(e) => {
e.stopPropagation();
toggleKeep(s.id);
}}
className={clsx(
"absolute top-1 left-1 z-menu p-0.5 rounded-sm bg-black/50 backdrop-blur-xs",
s.keep ? "text-accent" : "text-muted",
)}
title={s.keep ? t("editor.drop") : t("editor.keep")}
>
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</Pressable>
),
}))}
// The cuts between segments: dragged, or stepped with the arrows once focused.
handles={boundaries.map((b, bi) => ({
id: `cut-${bi}`,
label: t("editor.cutN", { n: bi + 1 }),
time: b,
min: segments[bi]!.start + MIN_SEG,
max: segments[bi + 1]!.end - MIN_SEG,
valueText: tc(b),
step: 0.1,
bigStep: 1,
onChange: (time: number) => moveBoundary(bi, time),
}))}
/>
</div>
{/* Each cut's remove button, under the track at the cut: a real button a click or Enter
works, off the cut's grab area, where a press drags it (a removal has no undo). */}
{boundaries.length > 0 && (
<div className="relative mt-1 h-6">
{boundaries.map((b, bi) => (
<div
key={`cut-${bi}`}
className="absolute top-0 -translate-x-1/2"
style={{ left: trackLeft(b, { from: 0, to: duration }) }}
>
<Pressable
onClick={() => {
// Refused while the clips are queued (the list holds still): then nothing is
// removed, and no focus is left waiting to be taken by a later change.
if (creating.current) return;
refocusAfterRemove.current = bi;
deleteBoundary(bi);
}}
// 24px, the target floor; white on its black disc in either theme.
className="flex p-1.5 rounded-full bg-black/70 text-white/80 hover:text-white"
aria-label={t("editor.removeCut", { n: bi + 1 })}
title={t("editor.removeCut", { n: bi + 1 })}
>
<X className="w-3 h-3" />
</Pressable>
</div>
))}
</div>
)}
{/* hover-scrub thumbnail */}
{strip && hoverTime != null && (
<div
className="absolute bottom-full mb-2 pointer-events-none z-chrome rounded-md overflow-hidden border border-border shadow-xl bg-black"
style={{ left: hoverLeft, width: HOVER_W }}
>
<div
style={{
width: HOVER_W,
height: HOVER_W / aspect,
...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))),
}}
/>
<div className="text-3xs text-center text-muted py-0.5 tabular-nums">
{tc(hoverTime)}
</div>
</div>
)}
</div>
{/* selected-segment editor */}
{sel && (
<div className={`${SURFACE.card} flex flex-wrap items-center gap-2 text-sm px-3 py-2`}>
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
<Button
variant="outline"
size="xs"
pressed={sel.keep}
onClick={() => toggleKeep(sel.id)}
icon={sel.keep ? <Eye /> : <EyeOff />}
>
{sel.keep ? t("editor.kept") : t("editor.dropped")}
</Button>
<div className="flex-1" />
<label className="flex items-center gap-1 text-muted">
{t("editor.in")}
<TimecodeField
className="w-24 tabular-nums"
size="sm"
field={inField}
errorId={timeErrorId}
disabled={selIdx === 0}
/>
</label>
<label className="flex items-center gap-1 text-muted">
{t("editor.out")}
<TimecodeField
className="w-24 tabular-nums"
size="sm"
field={outField}
errorId={timeErrorId}
disabled={selIdx === segments.length - 1}
/>
</label>
<span className="text-xs text-muted tabular-nums w-16 text-right">
{tc(sel.end - sel.start)}
</span>
{(inField.invalid || outField.invalid) && (
<div className="basis-full">
<TimecodeError id={timeErrorId} hours={hours} />
</div>
)}
</div>
)}
{/* output mode + summary */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<Button
key={m}
variant="outline"
size="sm"
pressed={output === m}
onClick={() => setOutput(m)}
disabled={m === "join" && kept.length < 2}
>
{t(`editor.output.${m}`)}
</Button>
))}
<div className="flex-1" />
<span className="text-xs text-muted">
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
</span>
</div>
{/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3">
<Button
variant="outline"
pressed={cropOn}
onClick={() => setCropOn((v) => !v)}
icon={<Crop />}
className="justify-start"
>
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button>
{!cropOn ? (
<div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate;
return (
<Button
key={m}
variant="outline"
pressed={on}
onClick={() => setAccurate(m === "accurate")}
title={t(`editor.${m}.hint`)}
className="flex-1"
>
{t(`editor.${m}.label`)}
</Button>
);
})}
</div>
) : (
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.cropReencode")}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
<Input
className="w-full"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultName}
/>
</div>
<ModalFooter className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => create.mutate()}
disabled={!valid}
loading={create.isPending}
icon={<Scissors className="w-4 h-4" />}
>
{willJoin
? t("editor.createJoin")
: kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
}