Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit b7e0d2b8ecb326a3aaba91912a252835e2454960
parent 3117d8b6f6c8e54e1aab5d943188c33bed748c21
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 26 Apr 2026 21:08:13 -0400

feat(editor): channel list/create/edit/delete UI with e2e tests

Adds full channel CRUD wired through common/controller/channels.ts.

Editor:
- /channels server-component table (slug, name, handling, video count,
  has playlist, has archive, last sync). Empty-state copy when none.
- /channels/new with ChannelFormClient + createChannelAction. Slug auto-
  derived from name via @sindresorhus/slugify when blank.
- /channels/[slug] with edit form (slug locked) + delete form gated by
  typed-confirmation. Pipeline / Transcription panels are stubs for
  Phases 6 and 7.
- Server actions in app/channels/actions.ts use useActionState shape
  (prev, formData) -> ActionResult so client wrappers can render inline
  errors. Each mutation calls revalidatePath.
- ChannelForm + parseChannelForm cover the full ChannelConfig schema
  (handling, name, url, audioFormat, cookiesFromBrowser, ytdlpExtraArgs).

Cypress:
- New fixtures: empty/, one-youtube-channel/, one-transcribe-channel/
- channels.cy.ts: 7 specs covering empty state, create with explicit slug,
  auto-slugify, duplicate-slug rejection, edit, typed-confirmation delete
  (wrong + right), and audio format prefill on transcribe channels.

Verified: 10/10 cypress specs pass; pnpm --filter export run build still
produces the existing static output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Diffstat:
Aeditor/app/channels/[slug]/page.tsx | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/_components/ChannelForm.tsx | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/_components/ChannelFormClient.tsx | 30++++++++++++++++++++++++++++++
Aeditor/app/channels/_components/DeleteChannelForm.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/_components/parseChannelForm.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/actions.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/new/page.tsx | 22++++++++++++++++++++++
Meditor/app/channels/page.tsx | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Aeditor/cypress/e2e/channels.cy.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/cypress/fixtures/test-transcripts/empty/.gitkeep | 0
Aeditor/cypress/fixtures/test-transcripts/one-transcribe-channel/channels/test-transcribe/config.json | 6++++++
Aeditor/cypress/fixtures/test-transcripts/one-youtube-channel/channels/test-youtube/config.json | 5+++++
Meditor/package.json | 1+
Mpnpm-lock.yaml | 3+++
14 files changed, 673 insertions(+), 5 deletions(-)

diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -0,0 +1,84 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { ChannelFormClient } from "../_components/ChannelFormClient"; +import { DeleteChannelForm } from "../_components/DeleteChannelForm"; +import { + deleteChannelAction, + updateChannelAction, + type ActionResult, +} from "../actions"; + +export const dynamic = "force-dynamic"; + +export default async function ChannelDetailPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const config = await readChannelConfig(getPaths(), slug); + if (!config) notFound(); + + const update = updateChannelAction.bind(null, slug); + const del = deleteChannelAction.bind(null, slug); + + return ( + <div className="flex flex-col gap-6"> + <div className="flex items-center gap-2 text-sm text-zinc-500"> + <Link href="/channels" className="underline hover:text-zinc-900 dark:hover:text-zinc-100"> + Channels + </Link> + <span>/</span> + <span className="font-mono">{slug}</span> + </div> + <h1 className="text-2xl font-semibold">{config.name ?? slug}</h1> + + <section className="flex flex-col gap-3"> + <h2 className="text-lg font-semibold">Configuration</h2> + <ChannelFormClient + action={ + update as ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult> + } + initial={{ slug, config }} + submitLabel="Save changes" + /> + </section> + + <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> + <h2 className="text-lg font-semibold">Pipeline</h2> + <p className="text-sm text-zinc-500"> + Store playlist / Download / Sync UI lands in Phase 6. + </p> + </section> + + {config.handling === "transcribe" && ( + <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> + <h2 className="text-lg font-semibold">Transcription</h2> + <p className="text-sm text-zinc-500"> + Whisper queue UI lands in Phase 7. + </p> + </section> + )} + + <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6"> + <h2 className="text-lg font-semibold text-red-700 dark:text-red-400"> + Danger zone + </h2> + <DeleteChannelForm + slug={slug} + action={ + del as ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult> + } + /> + </section> + </div> + ); +} diff --git a/editor/app/channels/_components/ChannelForm.tsx b/editor/app/channels/_components/ChannelForm.tsx @@ -0,0 +1,153 @@ +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; + +type Props = { + action: string | ((formData: FormData) => void | Promise<void>); + initial?: { slug: string; config: ChannelConfig }; + submitLabel: string; + errorMessage?: string; +}; + +export function ChannelForm({ + action, + initial, + submitLabel, + errorMessage, +}: Props) { + const isEdit = !!initial; + const c = initial?.config; + return ( + <form action={action} className="flex flex-col gap-4 max-w-xl"> + {errorMessage && ( + <div className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"> + {errorMessage} + </div> + )} + <Field + label="Name" + name="name" + required + defaultValue={c?.name ?? ""} + placeholder="Display name (e.g., TheQuartering)" + /> + {isEdit ? ( + <Field + label="Slug" + name="slug" + readOnly + defaultValue={initial!.slug} + hint="Slug is fixed once a channel is created." + /> + ) : ( + <Field + label="Slug" + name="slug" + placeholder="kebab-case (auto-derived from name if blank)" + /> + )} + <fieldset className="flex flex-col gap-2"> + <legend className="text-sm font-medium">Handling</legend> + <label className="flex items-center gap-2 text-sm"> + <input + type="radio" + name="handling" + value="youtube" + defaultChecked={c?.handling !== "transcribe"} + /> + YouTube (auto-subtitles via yt-dlp) + </label> + <label className="flex items-center gap-2 text-sm"> + <input + type="radio" + name="handling" + value="transcribe" + defaultChecked={c?.handling === "transcribe"} + /> + Transcribe (download audio, run whisper-cpp) + </label> + </fieldset> + <Field + label="URL" + name="url" + defaultValue={c?.url ?? ""} + placeholder="https://www.youtube.com/@channel/videos" + hint="Channel or playlist URL passed to yt-dlp." + /> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium"> + Audio format <span className="text-zinc-500 font-normal">(transcribe only)</span> + </span> + <select + name="audioFormat" + defaultValue={c?.audioFormat ?? ""} + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm" + > + <option value="">Default (m4a)</option> + <option value="m4a">m4a</option> + <option value="mp3">mp3</option> + <option value="opus">opus</option> + </select> + </label> + <Field + label="Cookies from browser" + name="cookiesFromBrowser" + defaultValue={c?.cookiesFromBrowser ?? ""} + placeholder="firefox / chrome / brave (optional)" + hint="Forwarded to yt-dlp's --cookies-from-browser." + /> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium"> + Extra yt-dlp args + <span className="text-zinc-500 font-normal"> (one per line)</span> + </span> + <textarea + name="ytdlpExtraArgs" + defaultValue={(c?.ytdlpExtraArgs ?? []).join("\n")} + rows={4} + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono" + /> + </label> + <div className="flex gap-2"> + <button + type="submit" + className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90" + > + {submitLabel} + </button> + </div> + </form> + ); +} + +function Field({ + label, + name, + defaultValue, + placeholder, + hint, + required, + readOnly, +}: { + label: string; + name: string; + defaultValue?: string; + placeholder?: string; + hint?: string; + required?: boolean; + readOnly?: boolean; +}) { + return ( + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">{label}</span> + <input + type="text" + name={name} + defaultValue={defaultValue} + placeholder={placeholder} + required={required} + readOnly={readOnly} + className={`rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm ${readOnly ? "opacity-60" : ""}`} + /> + {hint && <span className="text-xs text-zinc-500">{hint}</span>} + </label> + ); +} diff --git a/editor/app/channels/_components/ChannelFormClient.tsx b/editor/app/channels/_components/ChannelFormClient.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useActionState } from "react"; +import { ChannelForm } from "./ChannelForm"; +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; +import type { ActionResult } from "../actions"; + +type Props = { + action: ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult>; + initial?: { slug: string; config: ChannelConfig }; + submitLabel: string; +}; + +export function ChannelFormClient({ action, initial, submitLabel }: Props) { + const [state, formAction] = useActionState<ActionResult, FormData>( + action, + undefined, + ); + return ( + <ChannelForm + action={formAction} + initial={initial} + submitLabel={submitLabel} + errorMessage={state?.error} + /> + ); +} diff --git a/editor/app/channels/_components/DeleteChannelForm.tsx b/editor/app/channels/_components/DeleteChannelForm.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useActionState } from "react"; +import type { ActionResult } from "../actions"; + +type Props = { + slug: string; + action: ( + prev: ActionResult, + formData: FormData, + ) => Promise<ActionResult>; +}; + +export function DeleteChannelForm({ slug, action }: Props) { + const [state, formAction] = useActionState<ActionResult, FormData>( + action, + undefined, + ); + return ( + <form action={formAction} className="flex flex-col gap-2"> + <p className="text-sm text-zinc-500"> + Deleting will remove <code>transcripts/channels/{slug}/</code> and all + downloaded videos. Type the slug to confirm. + </p> + <div className="flex gap-2 items-start"> + <input + name="confirmSlug" + required + placeholder={slug} + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono" + /> + <button + type="submit" + className="px-3 py-1.5 rounded-md bg-red-600 text-white text-sm font-medium hover:bg-red-700" + > + Delete channel + </button> + </div> + {state?.error && ( + <p className="text-sm text-red-600 dark:text-red-400">{state.error}</p> + )} + </form> + ); +} diff --git a/editor/app/channels/_components/parseChannelForm.ts b/editor/app/channels/_components/parseChannelForm.ts @@ -0,0 +1,54 @@ +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; + +export type ParsedChannelForm = { + name: string; + slug: string; + config: ChannelConfig; +}; + +// Maps FormData -> ChannelConfig. Throws on invalid input so the server +// action can render an error message. +export function parseChannelForm(formData: FormData): ParsedChannelForm { + const name = String(formData.get("name") ?? "").trim(); + if (!name) throw new Error("Name is required"); + + const handlingRaw = String(formData.get("handling") ?? ""); + if (handlingRaw !== "youtube" && handlingRaw !== "transcribe") { + throw new Error("Handling must be 'youtube' or 'transcribe'"); + } + + const slug = String(formData.get("slug") ?? "").trim(); + + const url = stringOrUndef(formData, "url"); + const audioFormatRaw = String(formData.get("audioFormat") ?? ""); + const audioFormat: ChannelConfig["audioFormat"] | undefined = + audioFormatRaw === "m4a" || + audioFormatRaw === "mp3" || + audioFormatRaw === "opus" + ? audioFormatRaw + : undefined; + const cookiesFromBrowser = stringOrUndef(formData, "cookiesFromBrowser"); + const argsBlob = String(formData.get("ytdlpExtraArgs") ?? "").trim(); + const ytdlpExtraArgs = argsBlob + ? argsBlob + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + + const config: ChannelConfig = { + handling: handlingRaw, + name, + }; + if (url) config.url = url; + if (audioFormat) config.audioFormat = audioFormat; + if (cookiesFromBrowser) config.cookiesFromBrowser = cookiesFromBrowser; + if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs; + + return { name, slug, config }; +} + +function stringOrUndef(fd: FormData, key: string): string | undefined { + const v = String(fd.get(key) ?? "").trim(); + return v ? v : undefined; +} diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -0,0 +1,87 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import slugify from "@sindresorhus/slugify"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + channelExists, + createChannel, + deleteChannel, + readChannelConfig, + writeChannelConfig, +} from "yt-dlp-transcript-common/controller/channels"; +import { parseChannelForm } from "./_components/parseChannelForm"; + +export type ActionResult = { error: string } | undefined; + +export async function createChannelAction( + _prev: ActionResult, + formData: FormData, +): Promise<ActionResult> { + let parsed; + try { + parsed = parseChannelForm(formData); + } catch (e) { + return { error: (e as Error).message }; + } + const { name, config } = parsed; + const slug = parsed.slug || slugify(name); + if (!slug) { + return { error: "Could not derive a slug from the name" }; + } + const paths = getPaths(); + if (await channelExists(paths, slug)) { + return { error: `Channel "${slug}" already exists` }; + } + await createChannel(paths, slug, config); + revalidatePath("/channels"); + revalidatePath("/"); + redirect(`/channels/${slug}`); +} + +export async function updateChannelAction( + slug: string, + _prev: ActionResult, + formData: FormData, +): Promise<ActionResult> { + let parsed; + try { + parsed = parseChannelForm(formData); + } catch (e) { + return { error: (e as Error).message }; + } + const paths = getPaths(); + const existing = await readChannelConfig(paths, slug); + if (!existing) return { error: `Channel "${slug}" not found` }; + // Preserve writeback fields the form doesn't expose. + const merged = { + ...existing, + ...parsed.config, + }; + if (existing.lastSyncedAt) merged.lastSyncedAt = existing.lastSyncedAt; + if (existing.lastFullDownloadAt) { + merged.lastFullDownloadAt = existing.lastFullDownloadAt; + } + await writeChannelConfig(paths, slug, merged); + revalidatePath("/channels"); + revalidatePath(`/channels/${slug}`); + return undefined; +} + +export async function deleteChannelAction( + slug: string, + _prev: ActionResult, + formData: FormData, +): Promise<ActionResult> { + const confirm = String(formData.get("confirmSlug") ?? "").trim(); + if (confirm !== slug) { + return { + error: `Type the channel slug "${slug}" exactly to confirm deletion`, + }; + } + await deleteChannel(getPaths(), slug); + revalidatePath("/channels"); + revalidatePath("/"); + redirect("/channels"); +} diff --git a/editor/app/channels/new/page.tsx b/editor/app/channels/new/page.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; +import { ChannelFormClient } from "../_components/ChannelFormClient"; +import { createChannelAction } from "../actions"; + +export default function NewChannelPage() { + return ( + <div className="flex flex-col gap-4"> + <div className="flex items-center gap-2 text-sm text-zinc-500"> + <Link href="/channels" className="underline hover:text-zinc-900 dark:hover:text-zinc-100"> + Channels + </Link> + <span>/</span> + <span>new</span> + </div> + <h1 className="text-2xl font-semibold">New channel</h1> + <ChannelFormClient + action={createChannelAction} + submitLabel="Create channel" + /> + </div> + ); +} diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -1,10 +1,99 @@ -export default function ChannelsPage() { +import Link from "next/link"; +import { listChannels } from "yt-dlp-transcript-common/controller/channels"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; + +export const dynamic = "force-dynamic"; + +export default async function ChannelsPage() { + const channels = await listChannels(getPaths()); return ( <div className="flex flex-col gap-4"> - <h1 className="text-2xl font-semibold">Channels</h1> - <p className="text-sm text-zinc-500"> - Channel CRUD lands in Phase 4. - </p> + <div className="flex items-center justify-between"> + <h1 className="text-2xl font-semibold">Channels</h1> + <Link + href="/channels/new" + className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90" + > + New channel + </Link> + </div> + {channels.length === 0 ? ( + <p + data-testid="channels-empty" + className="text-sm text-zinc-500 border border-dashed border-zinc-300 dark:border-zinc-700 rounded p-4" + > + No channels yet. + </p> + ) : ( + <table + data-testid="channels-table" + className="text-sm border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden" + > + <thead className="bg-zinc-100 dark:bg-zinc-900"> + <tr> + <Th>Slug</Th> + <Th>Name</Th> + <Th>Handling</Th> + <Th className="text-right">Videos</Th> + <Th>Playlist</Th> + <Th>Archive</Th> + <Th>Last sync</Th> + </tr> + </thead> + <tbody> + {channels.map((c) => ( + <tr + key={c.slug} + data-testid={`channel-row-${c.slug}`} + className="border-t border-zinc-200 dark:border-zinc-800" + > + <Td className="font-mono"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + {c.slug} + </Link> + </Td> + <Td>{c.config.name ?? ""}</Td> + <Td>{c.config.handling}</Td> + <Td className="text-right">{c.videoCount}</Td> + <Td>{c.hasPlaylist ? "✓" : ""}</Td> + <Td>{c.hasArchive ? "✓" : ""}</Td> + <Td className="text-xs text-zinc-500"> + {c.config.lastSyncedAt + ? new Date(c.config.lastSyncedAt).toLocaleString() + : ""} + </Td> + </tr> + ))} + </tbody> + </table> + )} </div> ); } + +function Th({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <th className={`text-left font-medium px-3 py-2 ${className}`}> + {children} + </th> + ); +} + +function Td({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return <td className={`px-3 py-2 ${className}`}>{children}</td>; +} diff --git a/editor/cypress/e2e/channels.cy.ts b/editor/cypress/e2e/channels.cy.ts @@ -0,0 +1,90 @@ +describe("Channels", () => { + it("shows empty state with no channels", () => { + cy.resetData("empty"); + cy.visit("/channels"); + cy.get('[data-testid="channels-empty"]').should("exist"); + }); + + it("creates a youtube channel", () => { + cy.resetData("empty"); + cy.visit("/channels/new"); + cy.findByLabelText(/^name/i).type("New Test Channel"); + cy.findByLabelText(/^slug/i).clear().type("new-test"); + cy.findByLabelText(/^url/i).type( + "https://www.youtube.com/@newtest/videos", + ); + cy.findByRole("button", { name: /create channel/i }).click(); + cy.location("pathname", { timeout: 10000 }).should( + "eq", + "/channels/new-test", + ); + cy.task("readJson", "test-transcripts/channels/new-test/config.json").then( + (config) => { + expect(config).to.deep.equal({ + handling: "youtube", + name: "New Test Channel", + url: "https://www.youtube.com/@newtest/videos", + }); + }, + ); + }); + + it("auto-derives slug from name when blank", () => { + cy.resetData("empty"); + cy.visit("/channels/new"); + cy.findByLabelText(/^name/i).type("Auto Slug Channel"); + cy.findByRole("button", { name: /create channel/i }).click(); + cy.location("pathname", { timeout: 10000 }).should( + "eq", + "/channels/auto-slug-channel", + ); + }); + + it("rejects duplicate slug", () => { + cy.resetData("one-youtube-channel"); + cy.visit("/channels/new"); + cy.findByLabelText(/^name/i).type("Test YouTube Channel"); + cy.findByLabelText(/^slug/i).clear().type("test-youtube"); + cy.findByRole("button", { name: /create channel/i }).click(); + cy.contains(/already exists/i); + }); + + it("edits an existing channel", () => { + cy.resetData("one-youtube-channel"); + cy.visit("/channels/test-youtube"); + cy.findByLabelText(/^name/i).clear().type("Renamed Channel"); + cy.findByRole("button", { name: /save changes/i }).click(); + cy.contains("Renamed Channel"); + cy.task( + "readJson", + "test-transcripts/channels/test-youtube/config.json", + ).then((config) => { + expect((config as { name?: string }).name).to.equal("Renamed Channel"); + }); + }); + + it("requires typed-confirmation to delete", () => { + cy.resetData("one-youtube-channel"); + cy.visit("/channels/test-youtube"); + // Wrong slug → error + cy.findByPlaceholderText("test-youtube").type("wrong-slug"); + cy.findByRole("button", { name: /delete channel/i }).click(); + cy.contains(/type the channel slug/i); + cy.task("pathExists", "test-transcripts/channels/test-youtube").then( + (exists) => expect(exists).to.equal(true), + ); + // Correct slug → deletes + cy.findByPlaceholderText("test-youtube").clear().type("test-youtube"); + cy.findByRole("button", { name: /delete channel/i }).click(); + cy.location("pathname", { timeout: 10000 }).should("eq", "/channels"); + cy.task("pathExists", "test-transcripts/channels/test-youtube").then( + (exists) => expect(exists).to.equal(false), + ); + }); + + it("hides transcribe form fields for youtube channels (cosmetic)", () => { + cy.resetData("one-transcribe-channel"); + cy.visit("/channels/test-transcribe"); + cy.findByLabelText(/audio format/i).should("have.value", "m4a"); + }); +}); diff --git a/editor/cypress/fixtures/test-transcripts/empty/.gitkeep b/editor/cypress/fixtures/test-transcripts/empty/.gitkeep diff --git a/editor/cypress/fixtures/test-transcripts/one-transcribe-channel/channels/test-transcribe/config.json b/editor/cypress/fixtures/test-transcripts/one-transcribe-channel/channels/test-transcribe/config.json @@ -0,0 +1,6 @@ +{ + "handling": "transcribe", + "name": "Test Transcribe Channel", + "url": "https://odysee.com/@example", + "audioFormat": "m4a" +} diff --git a/editor/cypress/fixtures/test-transcripts/one-youtube-channel/channels/test-youtube/config.json b/editor/cypress/fixtures/test-transcripts/one-youtube-channel/channels/test-youtube/config.json @@ -0,0 +1,5 @@ +{ + "handling": "youtube", + "name": "Test YouTube Channel", + "url": "https://www.youtube.com/@example/videos" +} diff --git a/editor/package.json b/editor/package.json @@ -13,6 +13,7 @@ "e2e-dev:headless": "start-server-and-test dev:test http://localhost:3001 \"cypress run --e2e\"" }, "dependencies": { + "@sindresorhus/slugify": "^3.0.0", "next": "16.2.3", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: editor: dependencies: + '@sindresorhus/slugify': + specifier: ^3.0.0 + version: 3.0.0 next: specifier: 16.2.3 version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)