diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8a9f73a4..60cbcace 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,3 +36,8 @@ The Voom software is provided under the following license: > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE > SOFTWARE. + +## Solar Icons + +Solar Icon Set by 480 Design, licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). +Source: https://github.com/480-Design/Solar-Icon-Set. Icons are rendered using the MIT-licensed `@solar-icons/react` package. The muted microphone adds a diagonal stroke. diff --git a/docs/pr/project-browser/projects-dark.png b/docs/pr/project-browser/projects-dark.png new file mode 100644 index 00000000..b729040f Binary files /dev/null and b/docs/pr/project-browser/projects-dark.png differ diff --git a/docs/pr/project-browser/projects.png b/docs/pr/project-browser/projects.png new file mode 100644 index 00000000..51666ca9 Binary files /dev/null and b/docs/pr/project-browser/projects.png differ diff --git a/docs/pr/project-browser/raw.png b/docs/pr/project-browser/raw.png new file mode 100644 index 00000000..e126aa8b Binary files /dev/null and b/docs/pr/project-browser/raw.png differ diff --git a/docs/pr/project-browser/recording-hud.png b/docs/pr/project-browser/recording-hud.png new file mode 100644 index 00000000..ffcd5dc1 Binary files /dev/null and b/docs/pr/project-browser/recording-hud.png differ diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a2500fb5..7eae1e17 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -762,7 +762,7 @@ interface Window { commit?: boolean, ) => Promise<{ success: boolean; error?: string }>; cancelRecordingImport: () => Promise<{ success: boolean }>; - listRecordings: () => Promise< + listRecordings: (includeSources?: boolean) => Promise< import("../src/types/recordingLibrary").LibraryResult< import("../src/types/recordingLibrary").RecordingLibraryEntry[] > @@ -831,13 +831,29 @@ interface Window { path?: string; error?: string; }>; + showRecordingHud: () => Promise; + createProjectFile: ( + data: unknown, + thumbnail?: string | null, + ) => Promise<{ + success: boolean; + path?: string; + projectId?: string; + message?: string; + canceled?: boolean; + }>; + renameLibraryProject: (path: string, name: string) => Promise<{success: boolean; path?: string; error?: string}>; + trashProjectFiles: ( + paths: string[], + ) => Promise<{ success: boolean; deleted: string[]; errors: string[] }>; listProjectFiles: () => Promise<{ success: boolean; projectsDir?: string | null; entries: Array<{ path: string; name: string; - updatedAt: number; + createdAt?: number; + updatedAt: number; thumbnailPath: string | null; isCurrent: boolean; isInProjectsDirectory: boolean; diff --git a/electron/ipc/project/createUntitledProject.test.ts b/electron/ipc/project/createUntitledProject.test.ts new file mode 100644 index 00000000..261da980 --- /dev/null +++ b/electron/ipc/project/createUntitledProject.test.ts @@ -0,0 +1,26 @@ +import { afterEach, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createUntitledProject } from "./createUntitledProject"; +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); +it("publishes complete, uniquely named projects under concurrent creation without replacing existing projects", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-new-")); + dirs.push(dir); + await fs.writeFile(path.join(dir, "Untitled Project.recordly"), "original"); + const paths = await Promise.all( + [1, 2, 3].map((n) => createUntitledProject(dir, JSON.stringify({ id: n }))), + ); + expect(paths.map((p) => path.basename(p)).sort()).toEqual([ + "Untitled Project 1.recordly", + "Untitled Project 2.recordly", + "Untitled Project 3.recordly", + ]); + expect(await fs.readFile(path.join(dir, "Untitled Project.recordly"), "utf8")).toBe("original"); + for (let i = 0; i < paths.length; i++) + expect(JSON.parse(await fs.readFile(paths[i], "utf8"))).toEqual({ id: i + 1 }); + expect((await fs.readdir(dir)).filter((p) => p.endsWith(".tmp"))).toEqual([]); +}); diff --git a/electron/ipc/project/createUntitledProject.ts b/electron/ipc/project/createUntitledProject.ts new file mode 100644 index 00000000..b7bc3779 --- /dev/null +++ b/electron/ipc/project/createUntitledProject.ts @@ -0,0 +1,31 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +/** Publish a complete new project without overwriting a concurrent or existing save. */ +export async function createUntitledProject(directory: string, contents: string) { + await fs.mkdir(directory, { recursive: true }); + const temporary = path.join(directory, `.recordly-new-${randomUUID()}.tmp`); + try { + const file = await fs.open(temporary, "wx"); + try { + await file.writeFile(contents, "utf8"); + await file.sync(); + } finally { + await file.close(); + } + for (let suffix = 0; ; suffix++) { + const target = path.join( + directory, + `Untitled Project${suffix ? ` ${suffix}` : ""}.recordly`, + ); + try { + await fs.link(temporary, target); + return target; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + } + } finally { + await fs.rm(temporary, { force: true }); + } +} diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 7828fa8b..f6921d77 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -1,3 +1,4 @@ +import { hasFreshProjectThumbnail } from "./thumbnailFreshness"; import { existsSync, constants as fsConstants, realpathSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -335,10 +336,7 @@ export async function buildProjectLibraryEntry( } const thumbnailPath = getProjectThumbnailPath(normalizedPath); - const thumbnailExists = await fs - .access(thumbnailPath, fsConstants.R_OK) - .then(() => true) - .catch(() => false); + const thumbnailExists = await hasFreshProjectThumbnail(thumbnailPath, stats.mtimeMs); return { path: normalizedPath, @@ -352,6 +350,7 @@ export async function buildProjectLibraryEntry( "", ), updatedAt: stats.mtimeMs, + createdAt: stats.birthtimeMs || stats.ctimeMs, thumbnailPath: thumbnailExists ? thumbnailPath : null, isCurrent: Boolean( currentProjectPath && normalizePath(currentProjectPath) === normalizedPath, diff --git a/electron/ipc/project/renameLibraryProject.test.ts b/electron/ipc/project/renameLibraryProject.test.ts new file mode 100644 index 00000000..048b6cfb --- /dev/null +++ b/electron/ipc/project/renameLibraryProject.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { renameLibraryProject } from "./renameLibraryProject"; + +it("renames a library project with its preview without overwriting another project", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-rename-")); + const original = path.join(dir, "Original.recordly"), + target = path.join(dir, "Renamed.recordly"); + const sidecars = (file: string) => [`${file}.png`]; + try { + await fs.writeFile(original, "project"); + await fs.writeFile(`${original}.png`, "preview"); + await fs.writeFile(target, "keep"); + await expect( + renameLibraryProject(original, "Renamed", [original], sidecars), + ).rejects.toThrow(); + expect(await fs.readFile(target, "utf8")).toBe("keep"); + await fs.rm(target); + await expect( + renameLibraryProject(original, "../escape", [original], sidecars), + ).rejects.toThrow(); + await expect(renameLibraryProject(original, "Renamed", [], sidecars)).rejects.toThrow(); + expect(await renameLibraryProject(original, "Renamed", [original], sidecars)).toBe(target); + expect(await fs.readFile(target, "utf8")).toBe("project"); + expect(await fs.readFile(`${target}.png`, "utf8")).toBe("preview"); + await expect(fs.access(original)).rejects.toThrow(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/electron/ipc/project/renameLibraryProject.ts b/electron/ipc/project/renameLibraryProject.ts new file mode 100644 index 00000000..476646f0 --- /dev/null +++ b/electron/ipc/project/renameLibraryProject.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function renameLibraryProject( + source: string, + name: string, + allowedPaths: string[], + sidecars: (path: string) => string[], +) { + if (!allowedPaths.includes(source)) throw new Error("Project is not in the library"); + const clean = name.trim(); + if ( + !clean || + clean === "." || + clean === ".." || + /[<>:"/\\|?*]/.test(clean) || + [...clean].some((char) => char.charCodeAt(0) < 32) || + /[. ]$/.test(clean) + ) + throw new Error("Choose a valid project name"); + const target = path.join(path.dirname(source), `${clean}${path.extname(source)}`); + if (target === source) return target; + // An exclusive link prevents a rename from overwriting another project. + await fs.link(source, target); + const copied: string[] = []; + try { + const previous = sidecars(source), + next = sidecars(target); + for (let i = 0; i < previous.length; i++) { + try { + await fs.copyFile(previous[i], next[i], fs.constants.COPYFILE_EXCL); + copied.push(next[i]); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + await fs.unlink(source); + } catch (error) { + await Promise.all([target, ...copied].map((file) => fs.rm(file, { force: true }))); + throw error; + } + // Auxiliary files can be cleaned up independently once the project has moved. + await Promise.all( + sidecars(source).map((file) => fs.rm(file, { force: true }).catch(() => undefined)), + ); + return target; +} diff --git a/electron/ipc/project/thumbnailFreshness.test.ts b/electron/ipc/project/thumbnailFreshness.test.ts new file mode 100644 index 00000000..69355243 --- /dev/null +++ b/electron/ipc/project/thumbnailFreshness.test.ts @@ -0,0 +1,28 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { hasFreshProjectThumbnail } from "./thumbnailFreshness"; + +it("rejects legacy, stale, missing and broken previews while accepting a fresh high-resolution PNG", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preview-")); + const file = path.join(dir, "preview.png"); + try { + expect(await hasFreshProjectThumbnail(file, 0)).toBe(false); + const header = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(header); + header.writeUInt32BE(320, 16); + header.writeUInt32BE(180, 20); + await fs.writeFile(file, header); + expect(await hasFreshProjectThumbnail(file, 0)).toBe(false); + header.writeUInt32BE(1600, 16); + header.writeUInt32BE(1200, 20); + await fs.writeFile(file, header); + expect(await hasFreshProjectThumbnail(file, 0)).toBe(true); + expect(await hasFreshProjectThumbnail(file, Date.now() + 10000)).toBe(false); + await fs.writeFile(file, "broken"); + expect(await hasFreshProjectThumbnail(file, 0)).toBe(false); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/electron/ipc/project/thumbnailFreshness.ts b/electron/ipc/project/thumbnailFreshness.ts new file mode 100644 index 00000000..00db1986 --- /dev/null +++ b/electron/ipc/project/thumbnailFreshness.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; + +// Older releases wrote 320px previews. Hide those and previews predating edits; +// the editor replaces them with a full-resolution render when returning home. +export async function hasFreshProjectThumbnail(thumbnailPath: string, projectModifiedAt: number) { + let file: Awaited> | undefined; + try { + file = await fs.open(thumbnailPath, "r"); + const stat = await file.stat(); + if (stat.mtimeMs < projectModifiedAt) return false; + const header = Buffer.alloc(24); + const { bytesRead } = await file.read(header, 0, 24, 0); + return ( + bytesRead === 24 && + header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && + header.readUInt32BE(16) >= 1600 && + header.readUInt32BE(20) >= 1200 + ); + } catch { + return false; + } finally { + await file?.close(); + } +} diff --git a/electron/ipc/project/trashProjects.test.ts b/electron/ipc/project/trashProjects.test.ts new file mode 100644 index 00000000..fc110734 --- /dev/null +++ b/electron/ipc/project/trashProjects.test.ts @@ -0,0 +1,34 @@ +import { expect, it, vi } from "vitest"; +import { trashLibraryProjects } from "./trashProjects"; +it("rejects paths outside the project library before touching files", async () => { + const trash = vi.fn(); + await expect( + trashLibraryProjects(["/private/tmp/a.recordly", "/private/tmp/private.txt"], { + list: async () => ({ entries: [{ path: "/private/tmp/a.recordly" }] }), + trash, + thumbnailPath: (p) => p + ".png", + }), + ).rejects.toThrow("not in the library"); + expect(trash).not.toHaveBeenCalled(); +}); +it("trashes only selected project files, deduplicates paths, and reports partial failures", async () => { + const trash = vi.fn(async (p: string) => { + if (p.endsWith("b.recordly")) throw Error("locked"); + }); + const result = await trashLibraryProjects( + ["/private/tmp/a.recordly", "/private/tmp/a.recordly", "/private/tmp/b.recordly"], + { + list: async () => ({ + entries: [{ path: "/private/tmp/a.recordly" }, { path: "/private/tmp/b.recordly" }], + }), + trash, + thumbnailPath: (p) => p + ".missing.png", + }, + ); + expect(result.deleted).toEqual(["/private/tmp/a.recordly"]); + expect(result.errors).toEqual(["Could not trash b.recordly"]); + expect(trash.mock.calls.map(([p]) => p)).toEqual([ + "/private/tmp/a.recordly", + "/private/tmp/b.recordly", + ]); +}); diff --git a/electron/ipc/project/trashProjects.ts b/electron/ipc/project/trashProjects.ts new file mode 100644 index 00000000..8ef1e2c8 --- /dev/null +++ b/electron/ipc/project/trashProjects.ts @@ -0,0 +1,45 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { getProjectBackupPath } from "./atomicSave"; + +type Dependencies = { + list: () => Promise<{ entries: Array<{ path: string }> }>; + trash: (path: string) => Promise; + thumbnailPath: (path: string) => string; +}; +/** Only library project files may be trashed; referenced recordings are never touched. */ +export async function trashLibraryProjects(paths: unknown, deps: Dependencies) { + if ( + !Array.isArray(paths) || + !paths.length || + paths.length > 500 || + paths.some((p) => typeof p !== "string") + ) + throw new Error("Invalid project selection"); + const allowed = new Set((await deps.list()).entries.map((e) => path.resolve(e.path))); + const selected = [...new Set((paths as string[]).map((p) => path.resolve(p)))]; + if (selected.some((p) => !allowed.has(p))) throw new Error("Project is not in the library"); + const deleted: string[] = []; + const errors: string[] = []; + for (const projectPath of selected) { + try { + await deps.trash(projectPath); + deleted.push(projectPath); + for (const sidecar of [ + deps.thumbnailPath(projectPath), + getProjectBackupPath(projectPath), + ]) { + try { + await fs.access(sidecar); + await deps.trash(sidecar); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") + errors.push(`Could not trash project sidecar: ${sidecar}`); + } + } + } catch { + errors.push(`Could not trash ${path.basename(projectPath)}`); + } + } + return { deleted, errors }; +} diff --git a/electron/ipc/recording/library.test.ts b/electron/ipc/recording/library.test.ts index e8dbde70..d44bf102 100644 --- a/electron/ipc/recording/library.test.ts +++ b/electron/ipc/recording/library.test.ts @@ -327,3 +327,11 @@ it("restores on volumes without hard links and preserves conflicts", async () => link.mockRestore(); } }); + +it("Raw includes camera and audio sources without including metadata or symlinks", async () => { + const files = ["screen.mp4", "screen.webcam.mp4", "screen.mic.wav", "screen.system.m4a"]; + for (const name of [...files, "screen.cursor.json"]) await fs.writeFile(path.join(state.root, name), "fixture"); + await fs.symlink(path.join(state.root, "screen.mp4"), path.join(state.root, "linked.mp4")); + expect((await listRecordings(true)).map(entry => entry.name).sort()).toEqual(files.sort()); + expect((await listRecordings()).map(entry => entry.name)).toEqual(["screen.mp4"]); +}); diff --git a/electron/ipc/recording/library.ts b/electron/ipc/recording/library.ts index 8d945248..82684137 100644 --- a/electron/ipc/recording/library.ts +++ b/electron/ipc/recording/library.ts @@ -13,7 +13,7 @@ const isRecording = (name: string) => /\.(mp4|mov|webm|mkv|m4v)$/i.test(name) && !/[.-]webcam[.-]/i.test(name); const batchKey = (paths: string[]) => JSON.stringify([...new Set(paths)].sort()); -export function listRecordings(): Promise { +export function listRecordings(includeSources = false): Promise { const task = mutation.then(async () => { const root = await fs.realpath(await getRecordingsDir()); const server = getMediaServerBaseUrl(); @@ -31,7 +31,7 @@ export function listRecordings(): Promise { } const result: RecordingLibraryEntry[] = []; for (const entry of entries) { - if (!entry.isFile() || !isRecording(entry.name)) continue; + if (!entry.isFile() || !(includeSources ? /\.(mp4|mov|webm|mkv|m4v|wav|m4a|mp3|ogg|flac)$/i.test(entry.name) : isRecording(entry.name))) continue; const filePath = path.join(root, entry.name); const stat = await fs.stat(filePath); if (!stat.size) continue; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index 885e5ddb..0782c8dc 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -1,3 +1,6 @@ +import { renameLibraryProject } from "../project/renameLibraryProject"; +import { createUntitledProject } from "../project/createUntitledProject"; +import { trashLibraryProjects } from "../project/trashProjects"; import { getRecordingThumbnail } from "../recording/thumbnail"; import { listRecordings, setRecordingsRemoved } from "../recording/library"; import { importRecording, discardRecordingImport } from "../recording/importRecording"; @@ -214,6 +217,15 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( } export function registerProjectHandlers() { + ipcMain.handle("rename-library-project", async (_, source: string, name: string) => { + try { + const entries = await listProjectLibraryEntries(); + const target = await renameLibraryProject(source, name, entries.entries.map(entry => entry.path), value => [getProjectThumbnailPath(value), getProjectBackupPath(value)]); + if (currentProjectPath === source) setCurrentProjectPath(target); + await rememberRecentProject(target); + return {success: true, path: target}; + } catch(error) { return {success: false, error: String(error)}; } + }); const imports = new Map(); const pendingImports = new Map>(); const watchedImportSenders = new WeakSet(); @@ -258,9 +270,9 @@ export function registerProjectHandlers() { return { success: false, error: String(error) }; } }); - ipcMain.handle("list-recordings", async () => { + ipcMain.handle("list-recordings", async (_, includeSources?: boolean) => { try { - return { success: true, value: await listRecordings() }; + return { success: true, value: await listRecordings(includeSources === true) }; } catch (error) { return { success: false, error: String(error) }; } @@ -674,6 +686,48 @@ export function registerProjectHandlers() { } }); + ipcMain.handle( + "create-project-file", + async (_, projectData: unknown, thumbnailDataUrl?: string | null) => { + try { + const prepared = ensureProjectDataHasProjectId(projectData); + const target = await createUntitledProject( + await getProjectsDir(), + JSON.stringify(prepared.projectData, null, 2), + ); + setCurrentProjectPath(target); + await rememberRecentProject(target); + try { + await saveProjectThumbnail(target, thumbnailDataUrl); + } catch (error) { + console.warn("Could not save project thumbnail", error); + } + return { success: true, path: target, projectId: prepared.projectId }; + } catch (error) { + return { success: false, message: String(error) }; + } + }, + ); + ipcMain.handle("trash-project-files", async (_, paths: unknown) => { + try { + const result = await trashLibraryProjects(paths, { + list: listProjectLibraryEntries, + trash: (filePath) => shell.trashItem(filePath), + thumbnailPath: getProjectThumbnailPath, + }); + if (currentProjectPath && result.deleted.includes(path.resolve(currentProjectPath))) + setCurrentProjectPath(null); + await saveRecentProjectPaths( + (await loadRecentProjectPaths()).filter( + (p) => !result.deleted.includes(path.resolve(p)), + ), + ); + return { success: result.errors.length === 0, ...result }; + } catch (error) { + return { success: false, deleted: [], errors: [String(error)] }; + } + }); + ipcMain.handle("list-project-files", async () => { try { const library = await listProjectLibraryEntries(); diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index ba40df16..25dac821 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -1,7 +1,12 @@ +import { createRecordingEditorNavigation } from "../../recordingEditorNavigation"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { app, BrowserWindow, desktopCapturer, ipcMain, systemPreferences } from "electron"; -import { reassertHudOverlayMousePassthrough } from "../../windows"; +import { + createHudOverlayWindow, + getHudOverlayWindow, + reassertHudOverlayMousePassthrough, +} from "../../windows"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { getNativeMacWindowSources, @@ -119,6 +124,7 @@ export function registerSourceHandlers({ createSourceSelectorWindow: () => BrowserWindow; getSourceSelectorWindow: () => BrowserWindow | null; }) { + const recordingNavigation = createRecordingEditorNavigation(createEditorWindow); ipcMain.handle("get-sources", async (_, opts) => { const cacheKey = JSON.stringify({ types: opts?.types, @@ -589,12 +595,22 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } createSourceSelectorWindow(); }); + ipcMain.handle("show-recording-hud", (event) => { + recordingNavigation.setReturnWindow(BrowserWindow.fromWebContents(event.sender)); + const hud = getHudOverlayWindow(); + if (hud && !hud.isDestroyed()) { + hud.show(); + hud.focus(); + } else { + createHudOverlayWindow(); + } + }); ipcMain.handle("switch-to-editor", () => { console.log("[switch-to-editor] Opening editor window"); const sourceSelectorWin = getSourceSelectorWindow(); if (sourceSelectorWin && !sourceSelectorWin.isDestroyed()) { sourceSelectorWin.close(); } - createEditorWindow(); + recordingNavigation.open(); }); } diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 58f5425b..7f022136 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -65,6 +65,7 @@ export type RecordingSessionManifest = { export type ProjectLibraryEntry = { path: string; name: string; + createdAt?: number; updatedAt: number; thumbnailPath: string | null; isCurrent: boolean; diff --git a/electron/preload.ts b/electron/preload.ts index 3e977e0a..8665528b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -201,7 +201,8 @@ contextBridge.exposeInMainWorld("electronAPI", { }, getEditorMode: () => ipcRenderer.invoke("get-editor-mode"), onEditorModeChanged: (callback: (inEditor: boolean) => void) => { - const listener = (_event: Electron.IpcRendererEvent, inEditor: boolean) => callback(inEditor); + const listener = (_event: Electron.IpcRendererEvent, inEditor: boolean) => + callback(inEditor); ipcRenderer.on("editor-mode-changed", listener); return () => ipcRenderer.removeListener("editor-mode-changed", listener); }, @@ -512,6 +513,11 @@ contextBridge.exposeInMainWorld("electronAPI", { getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, + showRecordingHud: () => ipcRenderer.invoke("show-recording-hud"), + createProjectFile: (data: unknown, thumbnail?: string | null) => + ipcRenderer.invoke("create-project-file", data, thumbnail), + renameLibraryProject: (path: string, name: string) => ipcRenderer.invoke("rename-library-project", path, name), + trashProjectFiles: (paths: string[]) => ipcRenderer.invoke("trash-project-files", paths), switchToEditor: () => { return ipcRenderer.invoke("switch-to-editor"); }, @@ -798,7 +804,7 @@ contextBridge.exposeInMainWorld("electronAPI", { finishRecordingImport: (keepPath: string, commit?: boolean) => ipcRenderer.invoke("finish-recording-import", keepPath, commit), cancelRecordingImport: () => ipcRenderer.invoke("cancel-recording-import"), - listRecordings: () => ipcRenderer.invoke("list-recordings"), + listRecordings: (includeSources?: boolean) => ipcRenderer.invoke("list-recordings", includeSources), setRecordingsRemoved: (paths: string[], removed: boolean) => ipcRenderer.invoke("set-recordings-removed", paths, removed), importRecording: ( diff --git a/electron/recordingEditorNavigation.test.ts b/electron/recordingEditorNavigation.test.ts new file mode 100644 index 00000000..ab5860d1 --- /dev/null +++ b/electron/recordingEditorNavigation.test.ts @@ -0,0 +1,31 @@ +import type { BrowserWindow } from "electron"; +import { expect, it, vi } from "vitest"; +import { createRecordingEditorNavigation } from "./recordingEditorNavigation"; + +it("returns a finished HUD recording to its originating editor and consumes the destination", () => { + const create = vi.fn(); + const target = { + isDestroyed: () => false, + isMinimized: () => true, + reload: vi.fn(), + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + }; + const navigation = createRecordingEditorNavigation(create); + navigation.setReturnWindow(target as unknown as BrowserWindow); + navigation.open(); + expect(target.reload).toHaveBeenCalledOnce(); + expect(target.restore).toHaveBeenCalledOnce(); + expect(target.focus).toHaveBeenCalledOnce(); + expect(create).not.toHaveBeenCalled(); + navigation.open(); + expect(create).toHaveBeenCalledOnce(); +}); +it("opens an editor if the originating dashboard has closed", () => { + const create = vi.fn(); + const navigation = createRecordingEditorNavigation(create); + navigation.setReturnWindow({ isDestroyed: () => true } as BrowserWindow); + navigation.open(); + expect(create).toHaveBeenCalledOnce(); +}); diff --git a/electron/recordingEditorNavigation.ts b/electron/recordingEditorNavigation.ts new file mode 100644 index 00000000..a241de53 --- /dev/null +++ b/electron/recordingEditorNavigation.ts @@ -0,0 +1,24 @@ +import type { BrowserWindow } from "electron"; + +export function createRecordingEditorNavigation(createEditor: () => void) { + let returnWindow: BrowserWindow | null = null; + return { + setReturnWindow(window: BrowserWindow | null) { + returnWindow = window; + }, + open() { + const target = returnWindow; + returnWindow = null; + if (!target || target.isDestroyed()) { + createEditor(); + return; + } + // Home flushed this editor's project before launching the HUD. Reload + // from the finalized recording session, clearing the previous project UI. + target.reload(); + if (target.isMinimized()) target.restore(); + target.show(); + target.focus(); + }, + }; +} diff --git a/package-lock.json b/package-lock.json index 6dac10a9..7a2f7e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "@solar-icons/react": "^2.3.0", "@supabase/supabase-js": "^2.116.0", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", @@ -2405,6 +2406,19 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@solar-icons/react": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solar-icons/react/-/react-2.3.0.tgz", + "integrity": "sha512-ihrnDrdksuX76wsea9i0jlEtln0Vgkzj0TxhIJ2JRWhDSwvX+ItXT3KR30C6ldMmFrEMcbpRCfGxOIHNlOZr0g==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@spectrum-icons/ui": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.2.tgz", diff --git a/package.json b/package.json index e0a6c11b..168b0919 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@phosphor-icons/react": "^2.1.10", + "@solar-icons/react": "^2.3.0", "@supabase/supabase-js": "^2.116.0", "capturekit": "^1.0.13", "electron-updater": "^6.8.3", diff --git a/public/announcements/placeholder-1.svg b/public/announcements/placeholder-1.svg new file mode 100644 index 00000000..8aa9f2ad --- /dev/null +++ b/public/announcements/placeholder-1.svg @@ -0,0 +1 @@ +ANNOUNCEMENTSSpace for what’s next. diff --git a/public/announcements/placeholder-2.svg b/public/announcements/placeholder-2.svg new file mode 100644 index 00000000..78293785 --- /dev/null +++ b/public/announcements/placeholder-2.svg @@ -0,0 +1 @@ +COMING SOONMore to explore. diff --git a/src/components/announcements/AnnouncementDialog.tsx b/src/components/announcements/AnnouncementDialog.tsx index ca04d2ae..519e039d 100644 --- a/src/components/announcements/AnnouncementDialog.tsx +++ b/src/components/announcements/AnnouncementDialog.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ArrowRight, ArrowSquareOut, Megaphone } from "@phosphor-icons/react"; +import { ArrowLeft, ArrowRight, ArrowSquareOut, Megaphone } from "@/components/ui/icons"; import { useEffect, useRef, useState } from "react"; import { toast } from "@/components/ui/toast"; import { BUNDLED_ANNOUNCEMENT_FEED } from "@/content/announcements"; diff --git a/src/components/announcements/EditorAnnouncementBanner.tsx b/src/components/announcements/EditorAnnouncementBanner.tsx index 8d2af32b..d3f419ba 100644 --- a/src/components/announcements/EditorAnnouncementBanner.tsx +++ b/src/components/announcements/EditorAnnouncementBanner.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, ArrowSquareOut, X } from "@phosphor-icons/react"; +import { ArrowRight, ArrowSquareOut, X } from "@/components/ui/icons"; import { useEffect, useRef, useState } from "react"; import { toast } from "@/components/ui/toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/auth/RecordlySignInDialog.tsx b/src/components/auth/RecordlySignInDialog.tsx index 6c9657cc..c6f2cdef 100644 --- a/src/components/auth/RecordlySignInDialog.tsx +++ b/src/components/auth/RecordlySignInDialog.tsx @@ -1,5 +1,5 @@ import { useI18n } from "@/contexts/I18nContext"; -import { GoogleLogo, SignOut, XLogo } from "@phosphor-icons/react"; +import { GoogleLogo, SignOut, XLogo } from "@/components/ui/icons"; import type { User } from "@supabase/supabase-js"; import { type FormEvent, useEffect, useState } from "react"; import { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index ec7f7af7..5a28bee0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -10,7 +10,7 @@ import { VideoCameraIcon, VideoCameraSlashIcon, XIcon, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useRef } from "react"; import { RxDragHandleDots2 } from "react-icons/rx"; @@ -212,7 +212,6 @@ function LaunchWindowContent() { paused={paused} microphoneEnabled={microphoneEnabled} elapsed={elapsed} - onToggleMicrophone={() => setMicrophoneEnabled(!microphoneEnabled)} onPauseResume={paused ? resumeRecording : pauseRecording} onStopRecording={toggleRecording} onHideHud={() => window.electronAPI?.hudOverlayHide?.()} @@ -250,7 +249,7 @@ function LaunchWindowContent() { } /> - + )} @@ -364,7 +363,7 @@ function LaunchWindowContent() {
- +
void; onPauseResume: () => void; onStopRecording: () => void; onHideHud: () => void; onCancelRecording: () => void; formatTime: (seconds: number) => string; } - -export const RecordingControls = ({ +export function RecordingControls({ paused, microphoneEnabled, elapsed, - onToggleMicrophone, onPauseResume, onStopRecording, onHideHud, onCancelRecording, formatTime, -}: RecordingControlsProps) => { +}: RecordingControlsProps) { const t = useScopedT("launch"); - - const memoizedControls = useMemo(() => { - return ( - <> -
-
- - {paused ? t("recording.paused") : t("recording.rec")} - -
- + const actionClass = `size-9 min-w-9 rounded-full ${styles.electronNoDrag}`; + return ( +
+
+ className={`size-2 rounded-full ${paused ? "bg-warning" : `bg-danger ${styles.recDotBlink}`}`} + /> + {formatTime(elapsed)} - - - - - - - - - + {paused && ( + {t("recording.paused")} + )} +
+ - - - + {t("recording.micToggleDisabledTip")} + + + + + {paused ? t("recording.resume") : t("recording.pause")} + + + + + {t("recording.stop")} + + + - + {t("recording.hideHud")} + + - - ); - }, [ - paused, - microphoneEnabled, - elapsed, - onToggleMicrophone, - onPauseResume, - onStopRecording, - onHideHud, - onCancelRecording, - formatTime, - t, - ]); - - return memoizedControls; -}; + {t("recording.cancel")} + +
+ ); +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index c47b81ed..c5906697 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,5 +1,5 @@ import { ToggleButton } from "@heroui/react"; -import { AppWindowIcon, CaretUpIcon, MonitorIcon } from "@phosphor-icons/react"; +import { AppWindowIcon, CaretUpIcon, MonitorIcon } from "@/components/ui/icons"; import * as React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; diff --git a/src/components/launch/UpdateToastWindow.tsx b/src/components/launch/UpdateToastWindow.tsx index 8eeb1be0..c6dcc08f 100644 --- a/src/components/launch/UpdateToastWindow.tsx +++ b/src/components/launch/UpdateToastWindow.tsx @@ -4,7 +4,7 @@ import { CheckCircleIcon, DownloadSimpleIcon, WarningCircleIcon, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { useI18n } from "@/contexts/I18nContext"; diff --git a/src/components/launch/popovers/CountdownPopover.tsx b/src/components/launch/popovers/CountdownPopover.tsx index a0c4b669..9462a227 100644 --- a/src/components/launch/popovers/CountdownPopover.tsx +++ b/src/components/launch/popovers/CountdownPopover.tsx @@ -1,4 +1,4 @@ -import { TimerIcon } from "@phosphor-icons/react"; +import { TimerIcon } from "@/components/ui/icons"; import type { ReactElement } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import styles from "../LaunchWindow.module.css"; diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx index cc5eb0f0..52ff84c7 100644 --- a/src/components/launch/popovers/MicPopover.tsx +++ b/src/components/launch/popovers/MicPopover.tsx @@ -1,4 +1,4 @@ -import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react"; +import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@/components/ui/icons"; import { useScopedT } from "@/contexts/I18nContext"; import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 9a5a5290..ebe41780 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -8,7 +8,7 @@ import { SunIcon, MoonIcon, DesktopIcon, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import type { ReactElement } from "react"; import { useI18n } from "@/contexts/I18nContext"; import { useScopedT } from "@/contexts/I18nContext"; diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index 848ca7b2..e72546cd 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -1,6 +1,6 @@ import { ToggleButton } from "@heroui/react"; import { Button } from "@/components/ui/button"; -import { MicrophoneIcon, MicrophoneSlashIcon } from "@phosphor-icons/react"; +import { MicrophoneIcon, MicrophoneSlashIcon } from "@/components/ui/icons"; import type { ReactElement, ReactNode } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; diff --git a/src/components/launch/popovers/WebcamPopover.tsx b/src/components/launch/popovers/WebcamPopover.tsx index 0c04ed89..6a603697 100644 --- a/src/components/launch/popovers/WebcamPopover.tsx +++ b/src/components/launch/popovers/WebcamPopover.tsx @@ -3,7 +3,7 @@ import { EyeSlash as EyeOff, VideoCamera as Video, VideoCameraSlash as VideoOff, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { useScopedT } from "@/contexts/I18nContext"; import { DropdownItem, HudPopover } from "./PopoverScaffold"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; diff --git a/src/components/ui/account-avatar.test.ts b/src/components/ui/account-avatar.test.ts new file mode 100644 index 00000000..e1665ba5 --- /dev/null +++ b/src/components/ui/account-avatar.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vitest"; +import type { User } from "@supabase/supabase-js"; +import { getAccountProfile } from "./account-avatar"; +it("uses OAuth profile photos and falls back to name or email initials", () => { + const user = { + email: "alex@example.com", + user_metadata: { full_name: "Alex Smith", avatar_url: "https://example.com/photo.jpg" }, + } as User; + expect(getAccountProfile(user)).toEqual({ + name: "Alex Smith", + initials: "AS", + picture: "https://example.com/photo.jpg", + }); + expect( + getAccountProfile({ ...user, user_metadata: { picture: "https://example.com/google.jpg" } }) + .picture, + ).toBe("https://example.com/google.jpg"); + expect(getAccountProfile({ ...user, user_metadata: {} })).toEqual({ + name: "alex@example.com", + initials: "A", + picture: undefined, + }); + expect(getAccountProfile(null).initials).toBe("LP"); +}); diff --git a/src/components/ui/account-avatar.tsx b/src/components/ui/account-avatar.tsx new file mode 100644 index 00000000..d3f8f211 --- /dev/null +++ b/src/components/ui/account-avatar.tsx @@ -0,0 +1,52 @@ +import { Avatar } from "@heroui/react"; +import { createContext, useContext } from "react"; +import type { User } from "@supabase/supabase-js"; + +export const AccountProfileContext = createContext(null); +export function getAccountProfile(user: User | null, fallback?: string) { + const metadata = user?.user_metadata; + const name = [metadata?.full_name, metadata?.name, user?.email, fallback, "Local profile"].find( + (value) => typeof value === "string" && value.trim(), + ) as string; + const initials = name + .split("@")[0] + .split(/[\s._-]+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase(); + const picture = [metadata?.avatar_url, metadata?.picture].find( + (value) => typeof value === "string" && /^https?:\/\//.test(value), + ); + return { name, initials, picture }; +} +export function AccountAvatar({ + label, + className = "", + user, +}: { + label?: string; + className?: string; + user?: User | null; +}) { + const context = useContext(AccountProfileContext); + const profile = getAccountProfile(user === undefined ? context : user, label); + return ( + + {profile.picture && ( + + )} + + {profile.initials} + + + ); +} diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx new file mode 100644 index 00000000..bbc4015f --- /dev/null +++ b/src/components/ui/icons.tsx @@ -0,0 +1,298 @@ +import type { IconProps as SolarIconProps } from "@solar-icons/react/lib/types"; +import type { ComponentType, SVGProps } from "react"; +type IconProps = SVGProps & { + size?: string | number; + weight?: "regular" | "bold" | "fill" | "duotone" | "thin" | "light"; + mirrored?: boolean; +}; +// Outline and filled states always come from the same Solar icon. +const solar = + (Linear: ComponentType, Bold: ComponentType, slash = false) => + ({ weight, mirrored, size, ...props }: IconProps) => { + const Icon = weight === "fill" ? Bold : Linear; + const style = { ...props.style, ...(mirrored ? { transform: "scaleX(-1)" } : {}) }; + if (slash) + return ( + + + + + ); + return ( + + ); + }; +import { AddIcon as AddLinear } from "@solar-icons/react/linear/add"; +import { AddIcon as AddBold } from "@solar-icons/react/bold/add"; +import { AlignHorizontalCenterIcon as AlignHorizontalCenterLinear } from "@solar-icons/react/linear/align-horizontal-center"; +import { AlignHorizontalCenterIcon as AlignHorizontalCenterBold } from "@solar-icons/react/bold/align-horizontal-center"; +import { AlignLeftIcon as AlignLeftLinear } from "@solar-icons/react/linear/align-left"; +import { AlignLeftIcon as AlignLeftBold } from "@solar-icons/react/bold/align-left"; +import { AlignRightIcon as AlignRightLinear } from "@solar-icons/react/linear/align-right"; +import { AlignRightIcon as AlignRightBold } from "@solar-icons/react/bold/align-right"; +import { AltArrowDownIcon as AltArrowDownLinear } from "@solar-icons/react/linear/alt-arrow-down"; +import { AltArrowDownIcon as AltArrowDownBold } from "@solar-icons/react/bold/alt-arrow-down"; +import { AltArrowUpIcon as AltArrowUpLinear } from "@solar-icons/react/linear/alt-arrow-up"; +import { AltArrowUpIcon as AltArrowUpBold } from "@solar-icons/react/bold/alt-arrow-up"; +import { ArrowLeftIcon as ArrowLeftLinear } from "@solar-icons/react/linear/arrow-left"; +import { ArrowLeftIcon as ArrowLeftBold } from "@solar-icons/react/bold/arrow-left"; +import { ArrowRightIcon as ArrowRightLinear } from "@solar-icons/react/linear/arrow-right"; +import { ArrowRightIcon as ArrowRightBold } from "@solar-icons/react/bold/arrow-right"; +import { BookmarkIcon as BookmarkLinear } from "@solar-icons/react/linear/bookmark"; +import { BookmarkIcon as BookmarkBold } from "@solar-icons/react/bold/bookmark"; +import { BranchingPathsUpIcon as BranchingPathsUpLinear } from "@solar-icons/react/linear/branching-paths-up"; +import { BranchingPathsUpIcon as BranchingPathsUpBold } from "@solar-icons/react/bold/branching-paths-up"; +import { CameraIcon as CameraLinear } from "@solar-icons/react/linear/camera"; +import { CameraIcon as CameraBold } from "@solar-icons/react/bold/camera"; +import { ChatRoundIcon as ChatRoundLinear } from "@solar-icons/react/linear/chat-round"; +import { ChatRoundIcon as ChatRoundBold } from "@solar-icons/react/bold/chat-round"; +import { ChatRoundDotsIcon as ChatRoundDotsLinear } from "@solar-icons/react/linear/chat-round-dots"; +import { ChatRoundDotsIcon as ChatRoundDotsBold } from "@solar-icons/react/bold/chat-round-dots"; +import { CheckIcon as CheckLinear } from "@solar-icons/react/linear/check"; +import { CheckIcon as CheckBold } from "@solar-icons/react/bold/check"; +import { CheckCircleIcon as CheckCircleLinear } from "@solar-icons/react/linear/check-circle"; +import { CheckCircleIcon as CheckCircleBold } from "@solar-icons/react/bold/check-circle"; +import { ClapperboardIcon as ClapperboardLinear } from "@solar-icons/react/linear/clapperboard"; +import { ClapperboardIcon as ClapperboardBold } from "@solar-icons/react/bold/clapperboard"; +import { CloseIcon as CloseLinear } from "@solar-icons/react/linear/close"; +import { CloseIcon as CloseBold } from "@solar-icons/react/bold/close"; +import { CloudIcon as CloudLinear } from "@solar-icons/react/linear/cloud"; +import { CloudIcon as CloudBold } from "@solar-icons/react/bold/cloud"; +import { CloudUploadIcon as CloudUploadLinear } from "@solar-icons/react/linear/cloud-upload"; +import { CloudUploadIcon as CloudUploadBold } from "@solar-icons/react/bold/cloud-upload"; +import { CopyIcon as CopyLinear } from "@solar-icons/react/linear/copy"; +import { CopyIcon as CopyBold } from "@solar-icons/react/bold/copy"; +import { CropIcon as CropLinear } from "@solar-icons/react/linear/crop"; +import { CropIcon as CropBold } from "@solar-icons/react/bold/crop"; +import { CursorIcon as CursorLinear } from "@solar-icons/react/linear/cursor"; +import { CursorIcon as CursorBold } from "@solar-icons/react/bold/cursor"; +import { DangerCircleIcon as DangerCircleLinear } from "@solar-icons/react/linear/danger-circle"; +import { DangerCircleIcon as DangerCircleBold } from "@solar-icons/react/bold/danger-circle"; +import { DownloadIcon as DownloadLinear } from "@solar-icons/react/linear/download"; +import { DownloadIcon as DownloadBold } from "@solar-icons/react/bold/download"; +import { EyeIcon as EyeLinear } from "@solar-icons/react/linear/eye"; +import { EyeIcon as EyeBold } from "@solar-icons/react/bold/eye"; +import { EyeClosedIcon as EyeClosedLinear } from "@solar-icons/react/linear/eye-closed"; +import { EyeClosedIcon as EyeClosedBold } from "@solar-icons/react/bold/eye-closed"; +import { FileIcon as FileLinear } from "@solar-icons/react/linear/file"; +import { FileIcon as FileBold } from "@solar-icons/react/bold/file"; +import { FolderIcon as FolderLinear } from "@solar-icons/react/linear/folder"; +import { FolderIcon as FolderBold } from "@solar-icons/react/bold/folder"; +import { FolderOpenIcon as FolderOpenLinear } from "@solar-icons/react/linear/folder-open"; +import { FolderOpenIcon as FolderOpenBold } from "@solar-icons/react/bold/folder-open"; +import { FullScreenIcon as FullScreenLinear } from "@solar-icons/react/linear/full-screen"; +import { FullScreenIcon as FullScreenBold } from "@solar-icons/react/bold/full-screen"; +import { GalleryIcon as GalleryLinear } from "@solar-icons/react/linear/gallery"; +import { GalleryIcon as GalleryBold } from "@solar-icons/react/bold/gallery"; +import { HomeIcon as HomeLinear } from "@solar-icons/react/linear/home"; +import { HomeIcon as HomeBold } from "@solar-icons/react/bold/home"; +import { InfoCircleIcon as InfoCircleLinear } from "@solar-icons/react/linear/info-circle"; +import { InfoCircleIcon as InfoCircleBold } from "@solar-icons/react/bold/info-circle"; +import { KeyboardIcon as KeyboardLinear } from "@solar-icons/react/linear/keyboard"; +import { KeyboardIcon as KeyboardBold } from "@solar-icons/react/bold/keyboard"; +import { LogoutIcon as LogoutLinear } from "@solar-icons/react/linear/logout"; +import { LogoutIcon as LogoutBold } from "@solar-icons/react/bold/logout"; +import { MagicWand3Icon as MagicWand3Linear } from "@solar-icons/react/linear/magic-wand-3"; +import { MagicWand3Icon as MagicWand3Bold } from "@solar-icons/react/bold/magic-wand-3"; +import { MagnifierIcon as MagnifierLinear } from "@solar-icons/react/linear/magnifier"; +import { MagnifierIcon as MagnifierBold } from "@solar-icons/react/bold/magnifier"; +import { MagnifierZoomInIcon as MagnifierZoomInLinear } from "@solar-icons/react/linear/magnifier-zoom-in"; +import { MagnifierZoomInIcon as MagnifierZoomInBold } from "@solar-icons/react/bold/magnifier-zoom-in"; +import { MenuDotsIcon as MenuDotsLinear } from "@solar-icons/react/linear/menu-dots"; +import { MenuDotsIcon as MenuDotsBold } from "@solar-icons/react/bold/menu-dots"; +import { MenuDotsVerticalIcon as MenuDotsVerticalLinear } from "@solar-icons/react/linear/menu-dots-vertical"; +import { MenuDotsVerticalIcon as MenuDotsVerticalBold } from "@solar-icons/react/bold/menu-dots-vertical"; +import { MicrophoneIcon as MicrophoneLinear } from "@solar-icons/react/linear/microphone"; +import { MicrophoneIcon as MicrophoneBold } from "@solar-icons/react/bold/microphone"; +import { MinusIcon as MinusLinear } from "@solar-icons/react/linear/minus"; +import { MinusIcon as MinusBold } from "@solar-icons/react/bold/minus"; +import { MonitorIcon as MonitorLinear } from "@solar-icons/react/linear/monitor"; +import { MonitorIcon as MonitorBold } from "@solar-icons/react/bold/monitor"; +import { MoonIcon as MoonLinear } from "@solar-icons/react/linear/moon"; +import { MoonIcon as MoonBold } from "@solar-icons/react/bold/moon"; +import { MusicNotesIcon as MusicNotesLinear } from "@solar-icons/react/linear/music-notes"; +import { MusicNotesIcon as MusicNotesBold } from "@solar-icons/react/bold/music-notes"; +import { NotificationUnreadIcon as NotificationUnreadLinear } from "@solar-icons/react/linear/notification-unread"; +import { NotificationUnreadIcon as NotificationUnreadBold } from "@solar-icons/react/bold/notification-unread"; +import { PaletteIcon as PaletteLinear } from "@solar-icons/react/linear/palette"; +import { PaletteIcon as PaletteBold } from "@solar-icons/react/bold/palette"; +import { PauseIcon as PauseLinear } from "@solar-icons/react/linear/pause"; +import { PauseIcon as PauseBold } from "@solar-icons/react/bold/pause"; +import { PlayIcon as PlayLinear } from "@solar-icons/react/linear/play"; +import { PlayIcon as PlayBold } from "@solar-icons/react/bold/play"; +import { QuestionCircleIcon as QuestionCircleLinear } from "@solar-icons/react/linear/question-circle"; +import { QuestionCircleIcon as QuestionCircleBold } from "@solar-icons/react/bold/question-circle"; +import { RefreshIcon as RefreshLinear } from "@solar-icons/react/linear/refresh"; +import { RefreshIcon as RefreshBold } from "@solar-icons/react/bold/refresh"; +import { RestartIcon as RestartLinear } from "@solar-icons/react/linear/restart"; +import { RestartIcon as RestartBold } from "@solar-icons/react/bold/restart"; +import { ScissorsIcon as ScissorsLinear } from "@solar-icons/react/linear/scissors"; +import { ScissorsIcon as ScissorsBold } from "@solar-icons/react/bold/scissors"; +import { SettingsIcon as SettingsLinear } from "@solar-icons/react/linear/settings"; +import { SettingsIcon as SettingsBold } from "@solar-icons/react/bold/settings"; +import { ShareIcon as ShareLinear } from "@solar-icons/react/linear/share"; +import { ShareIcon as ShareBold } from "@solar-icons/react/bold/share"; +import { SkipNextIcon as SkipNextLinear } from "@solar-icons/react/linear/skip-next"; +import { SkipNextIcon as SkipNextBold } from "@solar-icons/react/bold/skip-next"; +import { SkipPreviousIcon as SkipPreviousLinear } from "@solar-icons/react/linear/skip-previous"; +import { SkipPreviousIcon as SkipPreviousBold } from "@solar-icons/react/bold/skip-previous"; +import { SpeedometerMiddleIcon as SpeedometerMiddleLinear } from "@solar-icons/react/linear/speedometer-middle"; +import { SpeedometerMiddleIcon as SpeedometerMiddleBold } from "@solar-icons/react/bold/speedometer-middle"; +import { SquareTopDownIcon as SquareTopDownLinear } from "@solar-icons/react/linear/square-top-down"; +import { SquareTopDownIcon as SquareTopDownBold } from "@solar-icons/react/bold/square-top-down"; +import { StopwatchIcon as StopwatchLinear } from "@solar-icons/react/linear/stopwatch"; +import { StopwatchIcon as StopwatchBold } from "@solar-icons/react/bold/stopwatch"; +import { SubtitlesIcon as SubtitlesLinear } from "@solar-icons/react/linear/subtitles"; +import { SubtitlesIcon as SubtitlesBold } from "@solar-icons/react/bold/subtitles"; +import { SunIcon as SunLinear } from "@solar-icons/react/linear/sun"; +import { SunIcon as SunBold } from "@solar-icons/react/bold/sun"; +import { TextBoldIcon as TextBoldLinear } from "@solar-icons/react/linear/text-bold"; +import { TextBoldIcon as TextBoldBold } from "@solar-icons/react/bold/text-bold"; +import { TextFieldIcon as TextFieldLinear } from "@solar-icons/react/linear/text-field"; +import { TextFieldIcon as TextFieldBold } from "@solar-icons/react/bold/text-field"; +import { TextItalicIcon as TextItalicLinear } from "@solar-icons/react/linear/text-italic"; +import { TextItalicIcon as TextItalicBold } from "@solar-icons/react/bold/text-italic"; +import { TextUnderlineIcon as TextUnderlineLinear } from "@solar-icons/react/linear/text-underline"; +import { TextUnderlineIcon as TextUnderlineBold } from "@solar-icons/react/bold/text-underline"; +import { TranslationIcon as TranslationLinear } from "@solar-icons/react/linear/translation"; +import { TranslationIcon as TranslationBold } from "@solar-icons/react/bold/translation"; +import { TrashBinTrashIcon as TrashBinTrashLinear } from "@solar-icons/react/linear/trash-bin-trash"; +import { TrashBinTrashIcon as TrashBinTrashBold } from "@solar-icons/react/bold/trash-bin-trash"; +import { UploadIcon as UploadLinear } from "@solar-icons/react/linear/upload"; +import { UploadIcon as UploadBold } from "@solar-icons/react/bold/upload"; +import { UserIcon as UserLinear } from "@solar-icons/react/linear/user"; +import { UserIcon as UserBold } from "@solar-icons/react/bold/user"; +import { UserCircleIcon as UserCircleLinear } from "@solar-icons/react/linear/user-circle"; +import { UserCircleIcon as UserCircleBold } from "@solar-icons/react/bold/user-circle"; +import { VideoFrameIcon as VideoFrameLinear } from "@solar-icons/react/linear/video-frame"; +import { VideoFrameIcon as VideoFrameBold } from "@solar-icons/react/bold/video-frame"; +import { VideocameraIcon as VideocameraLinear } from "@solar-icons/react/linear/videocamera"; +import { VideocameraIcon as VideocameraBold } from "@solar-icons/react/bold/videocamera"; +import { VideocameraOffIcon as VideocameraOffLinear } from "@solar-icons/react/linear/videocamera-off"; +import { VideocameraOffIcon as VideocameraOffBold } from "@solar-icons/react/bold/videocamera-off"; +import { VolumeCrossIcon as VolumeCrossLinear } from "@solar-icons/react/linear/volume-cross"; +import { VolumeCrossIcon as VolumeCrossBold } from "@solar-icons/react/bold/volume-cross"; +import { VolumeLoudIcon as VolumeLoudLinear } from "@solar-icons/react/linear/volume-loud"; +import { VolumeLoudIcon as VolumeLoudBold } from "@solar-icons/react/bold/volume-loud"; +import { VolumeSmallIcon as VolumeSmallLinear } from "@solar-icons/react/linear/volume-small"; +import { VolumeSmallIcon as VolumeSmallBold } from "@solar-icons/react/bold/volume-small"; +import { WidgetAddIcon as WidgetAddLinear } from "@solar-icons/react/linear/widget-add"; +import { WidgetAddIcon as WidgetAddBold } from "@solar-icons/react/bold/widget-add"; +import { WindowFrameIcon as WindowFrameLinear } from "@solar-icons/react/linear/window-frame"; +import { WindowFrameIcon as WindowFrameBold } from "@solar-icons/react/bold/window-frame"; +export const AlignCenterHorizontal = solar(AlignHorizontalCenterLinear, AlignHorizontalCenterBold); +export const AlignLeft = solar(AlignLeftLinear, AlignLeftBold); +export const AlignRight = solar(AlignRightLinear, AlignRightBold); +export const AppWindowIcon = solar(WindowFrameLinear, WindowFrameBold); +export const ArrowClockwise = solar(RefreshLinear, RefreshBold); +export const ArrowClockwiseIcon = solar(RefreshLinear, RefreshBold); +export const ArrowCounterClockwise = solar(RestartLinear, RestartBold); +export const ArrowLeft = solar(ArrowLeftLinear, ArrowLeftBold); +export const ArrowRight = solar(ArrowRightLinear, ArrowRightBold); +export const ArrowSquareOut = solar(SquareTopDownLinear, SquareTopDownBold); +export const BookmarkSimple = solar(BookmarkLinear, BookmarkBold); +export const BoundingBox = solar(FullScreenLinear, FullScreenBold); +export const Camera = solar(CameraLinear, CameraBold); +export const CaretDown = solar(AltArrowDownLinear, AltArrowDownBold); +export const CaretUpIcon = solar(AltArrowUpLinear, AltArrowUpBold); +export const ChatCircle = solar(ChatRoundLinear, ChatRoundBold); +export const ChatDots = solar(ChatRoundDotsLinear, ChatRoundDotsBold); +export const Check = solar(CheckLinear, CheckBold); +export const CheckCircleIcon = solar(CheckCircleLinear, CheckCircleBold); +export const Cloud = solar(CloudLinear, CloudBold); +export const CloudArrowUp = solar(CloudUploadLinear, CloudUploadBold); +export const Copy = solar(CopyLinear, CopyBold); +export const Crop = solar(CropLinear, CropBold); +export const DesktopIcon = solar(MonitorLinear, MonitorBold); +export const DotsThree = solar(MenuDotsLinear, MenuDotsBold); +export const DotsThreeVerticalIcon = solar(MenuDotsVerticalLinear, MenuDotsVerticalBold); +export const DownloadSimple = solar(DownloadLinear, DownloadBold); +export const DownloadSimpleIcon = solar(DownloadLinear, DownloadBold); +export const Eye = solar(EyeLinear, EyeBold); +export const EyeIcon = solar(EyeLinear, EyeBold); +export const EyeSlash = solar(EyeClosedLinear, EyeClosedBold); +export const EyeSlashIcon = solar(EyeClosedLinear, EyeClosedBold); +export const FilmSlate = solar(ClapperboardLinear, ClapperboardBold); +export const FilmStrip = solar(VideoFrameLinear, VideoFrameBold); +export const FolderOpen = solar(FolderOpenLinear, FolderOpenBold); +export const FolderOpenIcon = solar(FolderOpenLinear, FolderOpenBold); +export const FolderSimple = solar(FolderLinear, FolderBold); +export const FrameCorners = solar(GalleryLinear, GalleryBold); +export const Gear = solar(SettingsLinear, SettingsBold); +export const GearSix = solar(SettingsLinear, SettingsBold); +export const House = solar(HomeLinear, HomeBold); +export const Image = solar(GalleryLinear, GalleryBold); +export const ImageSquare = solar(GalleryLinear, GalleryBold); +export const Info = solar(InfoCircleLinear, InfoCircleBold); +export const MagnifyingGlass = solar(MagnifierLinear, MagnifierBold); +export const MagnifyingGlassPlus = solar(MagnifierZoomInLinear, MagnifierZoomInBold); +export const MicrophoneIcon = solar(MicrophoneLinear, MicrophoneBold); +export const MicrophoneSlashIcon = solar(MicrophoneLinear, MicrophoneBold, true); +export const MinusIcon = solar(MinusLinear, MinusBold); +export const MonitorIcon = solar(MonitorLinear, MonitorBold); +export const MoonIcon = solar(MoonLinear, MoonBold); +export const MusicNotes = solar(MusicNotesLinear, MusicNotesBold); +export const Pause = solar(PauseLinear, PauseBold); +export const PauseIcon = solar(PauseLinear, PauseBold); +export const Play = solar(PlayLinear, PlayBold); +export const PlayIcon = solar(PlayLinear, PlayBold); +export const Plus = solar(AddLinear, AddBold); +export const Question = solar(QuestionCircleLinear, QuestionCircleBold); +export const Scissors = solar(ScissorsLinear, ScissorsBold); +export const ShareNetwork = solar(ShareLinear, ShareBold); +export const SignOut = solar(LogoutLinear, LogoutBold); +export const SkipBack = solar(SkipPreviousLinear, SkipPreviousBold); +export const SkipForward = solar(SkipNextLinear, SkipNextBold); +export const SpeakerHigh = solar(VolumeLoudLinear, VolumeLoudBold); +export const SpeakerHighIcon = solar(VolumeLoudLinear, VolumeLoudBold); +export const SpeakerLow = solar(VolumeSmallLinear, VolumeSmallBold); +export const SpeakerX = solar(VolumeCrossLinear, VolumeCrossBold); +export const SpeakerXIcon = solar(VolumeCrossLinear, VolumeCrossBold); +export const SunIcon = solar(SunLinear, SunBold); +export const TextB = solar(TextBoldLinear, TextBoldBold); +export const TextItalic = solar(TextItalicLinear, TextItalicBold); +export const TextT = solar(TextFieldLinear, TextFieldBold); +export const TextUnderline = solar(TextUnderlineLinear, TextUnderlineBold); +export const TimerIcon = solar(StopwatchLinear, StopwatchBold); +export const Trash = solar(TrashBinTrashLinear, TrashBinTrashBold); +export const UploadSimple = solar(UploadLinear, UploadBold); +export const User = solar(UserLinear, UserBold); +export const UserCircle = solar(UserCircleLinear, UserCircleBold); +export const VideoCamera = solar(VideocameraLinear, VideocameraBold); +export const VideoCameraIcon = solar(VideocameraLinear, VideocameraBold); +export const VideoCameraSlash = solar(VideocameraOffLinear, VideocameraOffBold); +export const VideoCameraSlashIcon = solar(VideocameraOffLinear, VideocameraOffBold); +export const WarningCircleIcon = solar(DangerCircleLinear, DangerCircleBold); +export const X = solar(CloseLinear, CloseBold); +export const XIcon = solar(CloseLinear, CloseBold); +export const File = solar(FileLinear, FileBold); +export const ArrowsMerge = solar(BranchingPathsUpLinear, BranchingPathsUpBold); +export const ClosedCaptioning = solar(SubtitlesLinear, SubtitlesBold); +export const Cursor = solar(CursorLinear, CursorBold); +export const Gauge = solar(SpeedometerMiddleLinear, SpeedometerMiddleBold); +export const Keyboard = solar(KeyboardLinear, KeyboardBold); +export const MagicWand = solar(MagicWand3Linear, MagicWand3Bold); +export const Megaphone = solar(NotificationUnreadLinear, NotificationUnreadBold); +export const Palette = solar(PaletteLinear, PaletteBold); +export const PuzzlePiece = solar(WidgetAddLinear, WidgetAddBold); +export const TranslateIcon = solar(TranslationLinear, TranslationBold); +// Provider logos are brands, not selectable application icons. +export { GoogleLogo, XLogo } from "@phosphor-icons/react"; diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx index ae57094b..f4575173 100644 --- a/src/components/video-editor/AddCustomFontDialog.tsx +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -1,4 +1,4 @@ -import { Plus } from "@phosphor-icons/react"; +import { Plus } from "@/components/ui/icons"; import { useState } from "react"; import { toast } from "@/components/ui/toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 6736630c..c2a09ab8 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -14,7 +14,7 @@ import { TextT as Type, TextUnderline as Underline, UploadSimple as Upload, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { ColorControl, ColorPalette } from "@/components/ui/color-picker"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "@/components/ui/toast"; diff --git a/src/components/video-editor/CaptionListPanel.tsx b/src/components/video-editor/CaptionListPanel.tsx index f3aa6f7f..8edfb7f8 100644 --- a/src/components/video-editor/CaptionListPanel.tsx +++ b/src/components/video-editor/CaptionListPanel.tsx @@ -1,5 +1,5 @@ import { TextArea, Input } from "@/components/ui/input"; -import { ArrowsMerge, Scissors, Trash } from "@phosphor-icons/react"; +import { ArrowsMerge, Scissors, Trash } from "@/components/ui/icons"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { useScopedT } from "@/contexts/I18nContext"; diff --git a/src/components/video-editor/ExportSettingsMenu.tsx b/src/components/video-editor/ExportSettingsMenu.tsx index 9813c8e7..3c4fc65f 100644 --- a/src/components/video-editor/ExportSettingsMenu.tsx +++ b/src/components/video-editor/ExportSettingsMenu.tsx @@ -1,4 +1,4 @@ -import { DownloadSimple as Download, FilmSlate as Film, Image } from "@phosphor-icons/react"; +import { DownloadSimple as Download, FilmSlate as Film, Image } from "@/components/ui/icons"; import { Card, Label, Description, TagGroup, Tag } from "@heroui/react"; import type { ReactNode } from "react"; import { Button } from "@/components/ui/button"; diff --git a/src/components/video-editor/ExtensionManager.tsx b/src/components/video-editor/ExtensionManager.tsx index ac6b9e39..419a4292 100644 --- a/src/components/video-editor/ExtensionManager.tsx +++ b/src/components/video-editor/ExtensionManager.tsx @@ -1,5 +1,5 @@ import { Card } from "@heroui/react"; -import { PuzzlePiece } from "@phosphor-icons/react"; +import { PuzzlePiece } from "@/components/ui/icons"; export default function ExtensionManager() { return ( diff --git a/src/components/video-editor/FormatSelector.tsx b/src/components/video-editor/FormatSelector.tsx index e3288b4e..869b3699 100644 --- a/src/components/video-editor/FormatSelector.tsx +++ b/src/components/video-editor/FormatSelector.tsx @@ -1,5 +1,5 @@ import { ToggleButtonGroup, ToggleButton } from "@heroui/react"; -import { FilmSlate as Film, Image } from "@phosphor-icons/react"; +import { FilmSlate as Film, Image } from "@/components/ui/icons"; import { useScopedT } from "@/contexts/I18nContext"; import type { ExportFormat } from "@/lib/exporter/types"; diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 3a36b2c8..dc1e3c2d 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,7 +1,7 @@ import { Kbd } from "@heroui/react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { Gear as Settings2, Question as HelpCircle } from "@phosphor-icons/react"; +import { Gear as Settings2, Question as HelpCircle } from "@/components/ui/icons"; import { useEffect, useState } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx index 1a914a80..bf00385d 100644 --- a/src/components/video-editor/PlaybackControls.tsx +++ b/src/components/video-editor/PlaybackControls.tsx @@ -1,4 +1,4 @@ -import { Pause, Play, SpeakerHigh as Volume2, SpeakerX as VolumeX } from "@phosphor-icons/react"; +import { Pause, Play, SpeakerHigh as Volume2, SpeakerX as VolumeX } from "@/components/ui/icons"; import { useScopedT } from "@/contexts/I18nContext"; import { Surface } from "@heroui/react"; import { Slider } from "@/components/ui/slider"; diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index 9b686795..95763048 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -6,6 +6,7 @@ import { toFileUrl } from "./projectPersistence"; export type ProjectLibraryEntry = { path: string; name: string; + createdAt?: number; updatedAt: number; thumbnailPath: string | null; isCurrent: boolean; diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index d5711df8..b70345b6 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { Card, RadioGroup, Radio, Label, Description } from "@heroui/react"; import { ProgressBar } from "@heroui/react"; import { ColorControl, ColorPalette } from "@/components/ui/color-picker"; -import { Palette, Trash as Trash2, UploadSimple as Upload } from "@phosphor-icons/react"; +import { Palette, Trash as Trash2, UploadSimple as Upload } from "@/components/ui/icons"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "@/components/ui/toast"; diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index b09292be..a7a958d8 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -1,4 +1,4 @@ -import { Keyboard, ArrowCounterClockwise as RotateCcw } from "@phosphor-icons/react"; +import { Keyboard, ArrowCounterClockwise as RotateCcw } from "@/components/ui/icons"; import { useCallback, useEffect, useState } from "react"; import { toast } from "@/components/ui/toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx index 398ad3be..611e27d0 100644 --- a/src/components/video-editor/TutorialHelp.tsx +++ b/src/components/video-editor/TutorialHelp.tsx @@ -7,7 +7,7 @@ import { Scissors, GearSix as Settings2, XLogo as Twitter, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { diff --git a/src/components/video-editor/WallpaperGrid.tsx b/src/components/video-editor/WallpaperGrid.tsx index 25ebd9e2..5bb1acf7 100644 --- a/src/components/video-editor/WallpaperGrid.tsx +++ b/src/components/video-editor/WallpaperGrid.tsx @@ -1,5 +1,5 @@ import { Button, ToggleButton } from "@heroui/react"; -import { Check, Plus, X } from "@phosphor-icons/react"; +import { Check, Plus, X } from "@/components/ui/icons"; import { useEffect, useState } from "react"; import { getRenderableVideoUrl } from "@/lib/assetPath"; import { isVideoWallpaperSource } from "@/lib/wallpapers"; diff --git a/src/components/video-editor/cloud/CloudShareButton.tsx b/src/components/video-editor/cloud/CloudShareButton.tsx index 2c611bcc..abe52a00 100644 --- a/src/components/video-editor/cloud/CloudShareButton.tsx +++ b/src/components/video-editor/cloud/CloudShareButton.tsx @@ -1,5 +1,6 @@ +import { saveProjectShareLink } from "./projectShareLinks"; import { useI18n } from "@/contexts/I18nContext"; -import { Check, CloudArrowUp, Copy, ShareNetwork } from "@phosphor-icons/react"; +import { Check, CloudArrowUp, Copy, ShareNetwork } from "@/components/ui/icons"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "@/components/ui/toast"; import { Button } from "@/components/ui/button"; @@ -16,6 +17,7 @@ import { Label } from "@/components/ui/label"; const DEFAULT_CLOUD_ENDPOINT = "http://localhost:8787/api/upload"; type Props = { + projectPath?: string | null; filePath?: string; projectTitle: string; prepareFile?: () => Promise; @@ -27,6 +29,7 @@ type Props = { }; export function CloudShareButton({ + projectPath, filePath, projectTitle, prepareFile, @@ -127,6 +130,7 @@ export function CloudShareButton({ } setProgress(100); setShareUrl(result.shareUrl); + if (projectPath) { try { saveProjectShareLink(projectPath, result.shareUrl); } catch { toast.error("Share created, but its link could not be saved locally"); } } toast.success(t("editor.cloud.linkCreated")); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -135,7 +139,7 @@ export function CloudShareButton({ setPhase("idle"); setUploadId(undefined); } - }, [authToken, filePath, notes, prepareFile, projectTitle, t]); + }, [projectPath, authToken, filePath, notes, prepareFile, projectTitle, t]); const handleCancel = useCallback(async () => { cancelRequestedRef.current = true; diff --git a/src/components/video-editor/cloud/projectShareLinks.test.ts b/src/components/video-editor/cloud/projectShareLinks.test.ts new file mode 100644 index 00000000..bdf68560 --- /dev/null +++ b/src/components/video-editor/cloud/projectShareLinks.test.ts @@ -0,0 +1,21 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + getProjectShareLink, + moveProjectShareLink, + removeProjectShareLinks, + saveProjectShareLink, +} from "./projectShareLinks"; +afterEach(() => vi.unstubAllGlobals()); +it("retains share links across renames and clears obsolete paths", () => { + const values = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => values.get(key) || null, + setItem: (key: string, value: string) => values.set(key, value), + }); + saveProjectShareLink("old", "https://example.com/share"); + moveProjectShareLink("old", "new"); + expect(getProjectShareLink("new")).toBe("https://example.com/share"); + expect(getProjectShareLink("old")).toBeUndefined(); + removeProjectShareLinks(["new"]); + expect(getProjectShareLink("new")).toBeUndefined(); +}); diff --git a/src/components/video-editor/cloud/projectShareLinks.ts b/src/components/video-editor/cloud/projectShareLinks.ts new file mode 100644 index 00000000..58e27d9f --- /dev/null +++ b/src/components/video-editor/cloud/projectShareLinks.ts @@ -0,0 +1,34 @@ +const KEY = "recordly.project-share-links.v1"; +function readLinks(): Record { + try { + const stored = JSON.parse(localStorage.getItem(KEY) || "{}"); + if (!stored || typeof stored !== "object" || Array.isArray(stored)) return {}; + return Object.fromEntries( + Object.entries(stored).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && /^https?:\/\//.test(entry[1]), + ), + ); + } catch { + return {}; + } +} +export function getProjectShareLink(path: string): string | undefined { + return readLinks()[path]; +} +export function saveProjectShareLink(path: string, url: string) { + localStorage.setItem(KEY, JSON.stringify({ ...readLinks(), [path]: url })); +} +export function moveProjectShareLink(previous: string, next: string) { + if (previous === next) return; + const links = readLinks(); + if (!links[previous]) return; + links[next] = links[previous]; + delete links[previous]; + localStorage.setItem(KEY, JSON.stringify(links)); +} +export function removeProjectShareLinks(paths: string[]) { + const links = readLinks(); + for (const path of paths) delete links[path]; + localStorage.setItem(KEY, JSON.stringify(links)); +} diff --git a/src/components/video-editor/dashboard/Dashboard.tsx b/src/components/video-editor/dashboard/Dashboard.tsx new file mode 100644 index 00000000..129ab1d6 --- /dev/null +++ b/src/components/video-editor/dashboard/Dashboard.tsx @@ -0,0 +1,42 @@ +import { DashboardAnnouncements } from "./DashboardAnnouncements"; +import { Card, Modal } from "@heroui/react"; +import { DashboardDialogs } from "./DashboardDialogs"; +import { DashboardFilters } from "./DashboardFilters"; +import { DashboardGrid } from "./DashboardGrid"; +import { DashboardSidebar } from "./DashboardSidebar"; +import { DashboardToolbar } from "./DashboardToolbar"; +import type { DashboardProps } from "./types"; +import { useDashboardModel } from "./useDashboardModel"; +export function Dashboard(props: DashboardProps) { + const model = useDashboardModel(props); + const view = { ...props, ...model }; + return ( + <> + + + + + + + {model.section !== "settings" && + model.section !== "shared" && + model.section !== "raw" && ( + <> + + + + + )} + + + + + + + + + ); +} diff --git a/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx b/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx new file mode 100644 index 00000000..ca0e1c08 --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardAnnouncements.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { DashboardAnnouncements } from "./DashboardAnnouncements"; + +const banner = { src: "/banner.svg", alt: "Product news", href: "https://example.com/news" }; +describe("announcement configuration", () => { + it("hides disabled or empty announcements", () => { + expect( + renderToStaticMarkup( + , + ), + ).toBe(""); + expect( + renderToStaticMarkup( + , + ), + ).toBe(""); + }); + it("renders a linked single image without navigation", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('href="https://example.com/news"'); + expect(html).toContain('alt="Product news"'); + expect(html).not.toContain("Next announcement"); + expect(html).not.toContain("Previous announcement"); + }); + it("renders navigation for multiple banners and does not activate non-web links", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Next announcement"); + expect(html).not.toContain("javascript:"); + }); +}); diff --git a/src/components/video-editor/dashboard/DashboardAnnouncements.tsx b/src/components/video-editor/dashboard/DashboardAnnouncements.tsx new file mode 100644 index 00000000..81450cc0 --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardAnnouncements.tsx @@ -0,0 +1,97 @@ +import { Button, Card, Link } from "@heroui/react"; +import { useState } from "react"; +import { ArrowLeft, ArrowRight } from "@/components/ui/icons"; +import { toast } from "@/components/ui/toast"; +import { dashboardAnnouncements, type AnnouncementConfig } from "./announcementConfig"; + +export function DashboardAnnouncements({ + config = dashboardAnnouncements, +}: { + config?: AnnouncementConfig; +}) { + const { enabled, banners } = config; + const [index, setIndex] = useState(0); + if (!enabled || !banners.length) return null; + const active = index % banners.length; + const banner = banners[active]; + const image = ( + {banner.alt} + ); + const href = banner.href && /^https?:\/\//.test(banner.href) ? banner.href : undefined; + return ( + +
+ {href ? ( + { + event.preventDefault(); + void window.electronAPI + .openExternalUrl(href) + .catch(() => toast.error("Could not open announcement")); + }} + > + {image} + + ) : ( + image + )} +
+ {banners.length > 1 && ( +
+ +
+ {banners.map((banner, slide) => ( + + ))} +
+ +
+ )} +
+ ); +} diff --git a/src/components/video-editor/dashboard/DashboardDialogs.tsx b/src/components/video-editor/dashboard/DashboardDialogs.tsx new file mode 100644 index 00000000..1cc306b3 --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardDialogs.tsx @@ -0,0 +1,81 @@ +import { removeProjectShareLinks } from "../cloud/projectShareLinks"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +import type { DashboardProps } from "./types"; + +import type { DashboardModel } from "./useDashboardModel"; + +export function DashboardDialogs({ + folders, + busy, + confirmDelete, + setConfirmDelete, + selected, + run, + onDeleteProjects, + save, + setSelected, + setSelecting, +}: Pick< + DashboardProps & DashboardModel, + | "folders" + | "busy" + | "confirmDelete" + | "setConfirmDelete" + | "selected" + | "run" + | "onDeleteProjects" + | "save" + | "setSelected" + | "setSelecting" +>) { + return ( + <> + + + + + Delete {selected.length} project{selected.length === 1 ? "" : "s"}? + + +

+ Project files move to Trash. Source recordings are kept. +

+ + + + +
+
+ + ); +} diff --git a/src/components/video-editor/dashboard/DashboardFilters.tsx b/src/components/video-editor/dashboard/DashboardFilters.tsx new file mode 100644 index 00000000..849ee9be --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardFilters.tsx @@ -0,0 +1,129 @@ +import { Dropdown } from "@heroui/react"; +import { CaretDown, Trash } from "@/components/ui/icons"; + +import { Button } from "@/components/ui/button"; + +import type { DashboardModel } from "./useDashboardModel"; + +export function DashboardFilters({ + period, + setPeriod, + selecting, + setSelecting, + selected, + setSelected, + sort, + setSort, + visible, + busy, + setConfirmDelete, +}: Pick< + DashboardModel, + | "period" + | "setPeriod" + | "selecting" + | "setSelecting" + | "selected" + | "setSelected" + | "sort" + | "setSort" + | "visible" + | "busy" + | "setConfirmDelete" +>) { + return ( + <> +
+
+ {[ + ["all", "All"], + ["week", "Last 7 days"], + ["month", "Last 30 days"], + ].map(([id, label]) => ( + + ))} + +
+ + + + + setSort("recent")}> + Last edited + + setSort("created")}> + Last created + + setSort("name")}> + Name + + + + +
+ {selecting && ( +
+ + {selected.length} selected + + +
+ )} + + ); +} diff --git a/src/components/video-editor/dashboard/DashboardGrid.tsx b/src/components/video-editor/dashboard/DashboardGrid.tsx new file mode 100644 index 00000000..ef34d42a --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardGrid.tsx @@ -0,0 +1,117 @@ +import { RawRecordings } from "./RawRecordings"; +import { Cloud, ImageSquare } from "@/components/ui/icons"; + +import { Button } from "@/components/ui/button"; + +import { DashboardSettings } from "./DashboardSettings"; + +import { ProjectCard } from "./ProjectCard"; +import type { DashboardProps } from "./types"; + +import type { DashboardModel } from "./useDashboardModel"; + +export function DashboardGrid({ + error, + section, + accountLabel, + onSignIn, + onShareProject, + visible, + busy, + selecting, + toggleSelected, + onRenameProject, + folders, + save, + assignFolder, + selected, + openEntry, + query, + setQuery, + run, +}: Pick< + DashboardProps & DashboardModel, + | "error" + | "section" + | "accountLabel" + | "onSignIn" + | "onShareProject" + | "visible" + | "busy" + | "selecting" + | "toggleSelected" + | "onRenameProject" + | "folders" + | "save" + | "assignFolder" + | "selected" + | "openEntry" + | "query" + | "setQuery" + | "run" +>) { + return ( + <> +
+ {error && ( +

+ {error} +

+ )} + {section === "settings" ? ( + + ) : section === "raw" ? ( + + ) : section === "shared" ? ( +
+ +

+ {accountLabel + ? "Shared videos are managed in your cloud library." + : "Sign in to manage shared videos."} +

+ +
+ ) : visible.length ? ( +
    + {visible.map((entry) => ( + + ))} +
+ ) : ( +
+ +

{query ? "No matching projects" : "No projects yet"}

+ {query && ( + + )} +
+ )} +
+ + ); +} diff --git a/src/components/video-editor/dashboard/DashboardSettings.tsx b/src/components/video-editor/dashboard/DashboardSettings.tsx new file mode 100644 index 00000000..0fd96e45 --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardSettings.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/toast"; +export const DashboardSettingsContext = createContext(null); +export function DashboardSettings() { + const settingsContent = useContext(DashboardSettingsContext); + const [directory, setDirectory] = useState(""); + return ( +
+

Settings

+
+ {settingsContent} +
+
+

Projects folder

+ {directory && ( +

+ {directory} +

+ )} +
+ +
+

+ Named projects save automatically. Previews refresh when you return to Projects. +

+
+
+ ); +} diff --git a/src/components/video-editor/dashboard/DashboardSidebar.tsx b/src/components/video-editor/dashboard/DashboardSidebar.tsx new file mode 100644 index 00000000..ccb5f8b5 --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardSidebar.tsx @@ -0,0 +1,184 @@ +import { FolderRow } from "./FolderRow"; +import { + ArrowLeft, + Cloud, + File, + GearSix, + ImageSquare, + Plus, + UploadSimple, + UserCircle, +} from "@/components/ui/icons"; + +import { Button } from "@/components/ui/button"; + +import type { DashboardProps } from "./types"; + +import type { DashboardModel } from "./useDashboardModel"; +import { FOLDER_COLORS } from "./useProjectFolders"; + +export function DashboardSidebar({ + folders, + save, + section, + setSection, + metadata, + update, + navClass, + run, + onImportFile, + onOpenChange, + onSignIn, + accountLabel, +}: Pick< + DashboardProps & DashboardModel, + | "folders" + | "save" + | "section" + | "setSection" + | "metadata" + | "update" + | "navClass" + | "run" + | "onImportFile" + | "onOpenChange" + | "onSignIn" + | "accountLabel" +>) { + return ( + <> + + + ); +} diff --git a/src/components/video-editor/dashboard/DashboardToolbar.tsx b/src/components/video-editor/dashboard/DashboardToolbar.tsx new file mode 100644 index 00000000..622418c2 --- /dev/null +++ b/src/components/video-editor/dashboard/DashboardToolbar.tsx @@ -0,0 +1,49 @@ +import { MagnifyingGlass, Plus } from "@/components/ui/icons"; +import { type CSSProperties } from "react"; +import { Button } from "@/components/ui/button"; + +import { Input } from "@/components/ui/input"; + +import type { DashboardProps } from "./types"; + +import type { DashboardModel } from "./useDashboardModel"; + +export function DashboardToolbar({ + query, + setQuery, + run, + busy, +}: Pick) { + return ( + <> +
+
+ + setQuery(e.target.value)} + aria-label="Search projects" + placeholder="Search projects…" + className="h-11 w-full border-0 bg-default/30 pl-10 shadow-none" + /> +
+ +
+ + ); +} diff --git a/src/components/video-editor/dashboard/FolderColors.tsx b/src/components/video-editor/dashboard/FolderColors.tsx new file mode 100644 index 00000000..42a91c2d --- /dev/null +++ b/src/components/video-editor/dashboard/FolderColors.tsx @@ -0,0 +1,92 @@ +import { + ColorArea, + ColorField, + ColorPicker, + ColorSlider, + ColorSwatchPicker, + Input, + Popover, +} from "@heroui/react"; +import { FolderSimple, Plus, X } from "@/components/ui/icons"; +import { Button } from "@/components/ui/button"; +import { FOLDER_COLORS } from "./useProjectFolders"; + +export function FolderColors({ + value, + onChange, + custom, + onCustomChange, + name, +}: { + value: string; + onChange: (color: string) => void; + custom: string[]; + onCustomChange: (colors: string[]) => void; + name: string; +}) { + const colors = [...new Set([...FOLDER_COLORS, ...custom])]; + return ( + onChange(color.toString("hex"))}> + + + + + {colors.map((color) => ( + + + + + ))} + + + + + + + + + +
+ + + + +
+
+
+
+ ); +} diff --git a/src/components/video-editor/dashboard/FolderRow.tsx b/src/components/video-editor/dashboard/FolderRow.tsx new file mode 100644 index 00000000..7d2c74e8 --- /dev/null +++ b/src/components/video-editor/dashboard/FolderRow.tsx @@ -0,0 +1,103 @@ +import { Dropdown, Input } from "@heroui/react"; +import { DotsThree } from "@/components/ui/icons"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { FolderColors } from "./FolderColors"; +import type { ProjectFolder } from "./useProjectFolders"; + +export function FolderRow({ + folder, + active, + onSelect, + onChange, + onRemove, + colors, + onColors, +}: { + folder: ProjectFolder; + active: boolean; + onSelect: () => void; + onChange: (folder: ProjectFolder) => void; + onRemove: () => void; + colors: string[]; + onColors: (colors: string[]) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(folder.name); + const finish = () => { + if (draft.trim()) onChange({ ...folder, name: draft.trim() }); + setEditing(false); + }; + const edit = () => { + setDraft(folder.name); + setEditing(true); + }; + return ( +
+ onChange({ ...folder, color })} + custom={colors} + onCustomChange={onColors} + /> + {editing ? ( +
{ + event.preventDefault(); + finish(); + }} + > + setDraft(event.target.value)} + onBlur={finish} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setEditing(false); + } + }} + /> +
+ ) : ( + + )} + + + + + + Rename + + + Remove folder + + + + +
+ ); +} diff --git a/src/components/video-editor/dashboard/ProjectCard.tsx b/src/components/video-editor/dashboard/ProjectCard.tsx new file mode 100644 index 00000000..ac9d1f19 --- /dev/null +++ b/src/components/video-editor/dashboard/ProjectCard.tsx @@ -0,0 +1,254 @@ +import { AccountAvatar } from "@/components/ui/account-avatar"; +import { Dropdown } from "@heroui/react"; +import { Check, DotsThree, FolderSimple, Plus } from "@/components/ui/icons"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { getProjectShareLink, moveProjectShareLink } from "../cloud/projectShareLinks"; +import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; +import { ProjectThumbnail } from "./ProjectThumbnail"; +import type { DashboardProps } from "./types"; +import type { DashboardModel } from "./useDashboardModel"; + +type Props = Pick< + DashboardProps & DashboardModel, + | "accountLabel" + | "busy" + | "selecting" + | "selected" + | "toggleSelected" + | "openEntry" + | "run" + | "onShareProject" + | "onRenameProject" + | "folders" + | "save" + | "assignFolder" +> & { entry: ProjectLibraryEntry }; +export function ProjectCard({ + accountLabel, + entry, + busy, + selecting, + selected, + toggleSelected, + openEntry, + run, + onShareProject, + onRenameProject, + folders, + save, + assignFolder, +}: Props) { + const [editing, setEditing] = useState(false); + const renaming = useRef(false); + const [name, setName] = useState(entry.name); + const assignedFolders = folders.filter((folder) => folder.paths.includes(entry.path)); + const folder = assignedFolders[0]; + const shareUrl = getProjectShareLink(entry.path); + const rename = () => { + if (renaming.current) return; + renaming.current = true; + void run(async () => { + if (!name.trim() || name.trim() === entry.name) { + setEditing(false); + return; + } + const target = await onRenameProject(entry.path, name.trim()); + save( + folders.map((folder) => ({ + ...folder, + paths: folder.paths.map((path) => (path === entry.path ? target : path)), + })), + ); + moveProjectShareLink(entry.path, target); + setEditing(false); + }).finally(() => { + renaming.current = false; + }); + }; + return ( +
  • + +
    + +
    + {editing ? ( +
    { + event.preventDefault(); + rename(); + }} + > + setName(event.target.value)} + onBlur={rename} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setEditing(false); + } + }} + /> +
    + ) : ( +

    + {entry.name} +

    + )} +
    +

    + {new Date(entry.updatedAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })} +

    +
    + {assignedFolders.map((item) => ( + + ))} + + + + + {folders.map((item) => ( + assignFolder(entry.path, item.id)} + > + + {item.name} + {item.paths.includes(entry.path) && ( + + )} + + ))} + {folder && ( + assignFolder(entry.path, "none")} + > + Remove from all folders + + )} + {!folders.length && ( + + Create a folder in the sidebar + + )} + + + +
    +
    +
    + + + + + openEntry(entry)}> + Open project + + { + setName(entry.name); + setEditing(true); + }} + > + Rename + + + void run(async () => { + if (shareUrl) + await window.electronAPI.openExternalUrl(shareUrl); + else await onShareProject(entry.path); + }) + } + > + {shareUrl ? "View in web" : "Share"} + + + void run(async () => { + await window.electronAPI.revealInFolder(entry.path); + }) + } + > + Show in folder + + + + +
    +
  • + ); +} diff --git a/src/components/video-editor/dashboard/ProjectThumbnail.tsx b/src/components/video-editor/dashboard/ProjectThumbnail.tsx new file mode 100644 index 00000000..f41b51fe --- /dev/null +++ b/src/components/video-editor/dashboard/ProjectThumbnail.tsx @@ -0,0 +1,33 @@ +import { ImageSquare } from "@/components/ui/icons"; +import { useState } from "react"; +import { toFileUrl } from "../projectPersistence"; +export function ProjectThumbnail({ + path, + revision = 0, +}: { + path: string | null; + revision?: number; +}) { + const [failedSource, setFailedSource] = useState(null); + const sourceKey = `${path}:${revision}`; + return ( +
    + {path && failedSource !== sourceKey ? ( + setFailedSource(sourceKey)} + className="h-full w-full object-contain" + /> + ) : ( + + )} +
    + ); +} diff --git a/src/components/video-editor/dashboard/RawRecordings.tsx b/src/components/video-editor/dashboard/RawRecordings.tsx new file mode 100644 index 00000000..69b07fb6 --- /dev/null +++ b/src/components/video-editor/dashboard/RawRecordings.tsx @@ -0,0 +1,142 @@ +import { Button, Input, Modal } from "@heroui/react"; +import { useCallback, useEffect, useState } from "react"; +import { File, FolderOpen } from "@/components/ui/icons"; +import { toast } from "@/components/ui/toast"; +import type { RecordingLibraryEntry } from "@/types/recordingLibrary"; +export function RawRecordings() { + const [entries, setEntries] = useState([]); + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [preview, setPreview] = useState(null); + const refresh = useCallback(async () => { + setLoading(true); + setError(""); + try { + const result = await window.electronAPI.listRecordings(true); + if (!result.success) throw Error(result.error); + setEntries(result.value); + } catch (error) { + setError(error instanceof Error ? error.message : "Could not load raw recordings"); + } finally { + setLoading(false); + } + }, []); + useEffect(() => { + void refresh(); + }, [refresh]); + const reveal = async (path?: string) => { + try { + const result = path + ? { success: true, path } + : await window.electronAPI.getRecordingsDirectory(); + if (!result.success || !result.path) throw Error("Could not open recordings folder"); + await window.electronAPI.revealInFolder(result.path); + } catch { + toast.error("Could not show recordings in folder"); + } + }; + const visible = entries.filter((entry) => + entry.name.toLowerCase().includes(query.trim().toLowerCase()), + ); + return ( +
    +
    +
    +

    Raw

    +

    + Original screen recordings, camera footage, and audio files. +

    +
    + +
    + setQuery(event.target.value)} + className="mb-6 w-full" + /> + {loading ? ( +

    + Loading recordings… +

    + ) : error ? ( +
    +

    {error}

    + +
    + ) : visible.length ? ( +
      + {visible.map((entry) => ( +
    • + + + +
    • + ))} +
    + ) : ( +

    + {query ? "No matching raw files" : "No raw recordings yet"} +

    + )} + { + if (!open) setPreview(null); + }} + > + + + + + + {preview?.name} + + + {preview && + (/\.(wav|m4a|mp3|ogg|flac)$/i.test(preview.name) ? ( + + + + + +
    + ); +} diff --git a/src/components/video-editor/dashboard/announcementConfig.ts b/src/components/video-editor/dashboard/announcementConfig.ts new file mode 100644 index 00000000..701f3b1b --- /dev/null +++ b/src/components/video-editor/dashboard/announcementConfig.ts @@ -0,0 +1,12 @@ +export type AnnouncementBanner = { src: string; alt: string; href?: string }; +export type AnnouncementConfig = { enabled: boolean; banners: AnnouncementBanner[] }; + +/** Code-only configuration. One banner hides navigation; an empty list hides the card. */ +export const dashboardAnnouncements: AnnouncementConfig = { + enabled: true, + banners: [ + { src: "announcements/placeholder-1.svg", alt: "Announcement banner placeholder 1" }, + { src: "announcements/placeholder-2.svg", alt: "Announcement banner placeholder 2" }, + // Optional href: "https://..." opens the banner destination in the default browser. + ], +}; diff --git a/src/components/video-editor/dashboard/types.ts b/src/components/video-editor/dashboard/types.ts new file mode 100644 index 00000000..0f78f692 --- /dev/null +++ b/src/components/video-editor/dashboard/types.ts @@ -0,0 +1,14 @@ +import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; +export type DashboardProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + entries: ProjectLibraryEntry[]; + onOpenProject: (path: string) => Promise; + onImportFile: () => Promise; + error: string | null; + onSignIn: () => void; + onDeleteProjects: (paths: string[]) => Promise; + onRenameProject: (path: string, name: string) => Promise; + onShareProject: (path: string) => Promise; + accountLabel?: string; +}; diff --git a/src/components/video-editor/dashboard/useDashboardMetadata.ts b/src/components/video-editor/dashboard/useDashboardMetadata.ts new file mode 100644 index 00000000..c2e97ffa --- /dev/null +++ b/src/components/video-editor/dashboard/useDashboardMetadata.ts @@ -0,0 +1,31 @@ +import { useState } from "react"; +import { toast } from "@/components/ui/toast"; + +type Metadata = { colors: string[] }; +const KEY = "recordly.dashboard-metadata.v1"; +function load(): Metadata { + try { + const value = JSON.parse(localStorage.getItem(KEY) || "{}"); + return { + colors: Array.isArray(value.colors) + ? value.colors.filter( + (c: unknown) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c), + ) + : [], + }; + } catch { + return { colors: [] }; + } +} +export function useDashboardMetadata() { + const [metadata, setMetadata] = useState(load); + const update = (next: Metadata) => { + try { + localStorage.setItem(KEY, JSON.stringify(next)); + setMetadata(next); + } catch { + toast.error("Could not save library preferences"); + } + }; + return { metadata, update }; +} diff --git a/src/components/video-editor/dashboard/useDashboardModel.ts b/src/components/video-editor/dashboard/useDashboardModel.ts new file mode 100644 index 00000000..5ced875e --- /dev/null +++ b/src/components/video-editor/dashboard/useDashboardModel.ts @@ -0,0 +1,110 @@ +import { useMemo, useState } from "react"; +import { toast } from "@/components/ui/toast"; +import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; +import type { DashboardProps } from "./types"; +import { useDashboardMetadata } from "./useDashboardMetadata"; +import { useProjectFolders } from "./useProjectFolders"; +export function useDashboardModel({ entries, onOpenChange, onOpenProject }: DashboardProps) { + const { metadata, update } = useDashboardMetadata(); + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState([]); + const [confirmDelete, setConfirmDelete] = useState(false); + const toggleSelected = (path: string) => + setSelected((prev) => + prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], + ); + const assignFolder = (path: string, id: string) => + save( + folders.map((f) => ({ + ...f, + paths: + f.id === id + ? f.paths.includes(path) + ? f.paths.filter((p) => p !== path) + : [...f.paths, path] + : id === "none" + ? f.paths.filter((p) => p !== path) + : f.paths, + })), + ); + const [query, setQuery] = useState(""); + const [period, setPeriod] = useState("all"); + const [sort, setSort] = useState("recent"); + const [section, setSection] = useState("projects"); + const [busy, setBusy] = useState(false); + const { folders, save } = useProjectFolders(); + const visible = useMemo( + () => + entries + .filter((entry) => { + const cutoff = + period === "week" + ? Date.now() - 7 * 86400000 + : period === "month" + ? Date.now() - 30 * 86400000 + : 0; + const folder = folders.find((f) => f.id === section); + return ( + entry.updatedAt >= cutoff && + entry.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) && + (!folder || folder.paths.includes(entry.path)) + ); + }) + .sort((a, b) => + sort === "name" + ? a.name.localeCompare(b.name) + : sort === "created" + ? (b.createdAt ?? b.updatedAt) - (a.createdAt ?? a.updatedAt) + : b.updatedAt - a.updatedAt, + ), + [entries, query, period, sort, folders, section], + ); + const run = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await action(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Could not complete action"); + } finally { + setBusy(false); + } + }; + const openEntry = (entry: ProjectLibraryEntry) => + entry.isCurrent + ? onOpenChange(false) + : void run(async () => { + await onOpenProject(entry.path); + }); + const navClass = (active: boolean) => + `h-10 w-full justify-start gap-3 px-3 text-[13px] ${active ? "bg-default/50 font-medium" : "text-muted-foreground"}`; + + return { + metadata, + update, + selecting, + setSelecting, + selected, + setSelected, + confirmDelete, + setConfirmDelete, + toggleSelected, + assignFolder, + query, + setQuery, + period, + setPeriod, + sort, + setSort, + section, + setSection, + busy, + folders, + save, + visible, + run, + openEntry, + navClass, + }; +} +export type DashboardModel = ReturnType; diff --git a/src/components/video-editor/dashboard/useProjectFolders.ts b/src/components/video-editor/dashboard/useProjectFolders.ts new file mode 100644 index 00000000..6fe9330f --- /dev/null +++ b/src/components/video-editor/dashboard/useProjectFolders.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { toast } from "@/components/ui/toast"; +export const FOLDER_COLORS = ["#929292", "#de85ac", "#d6ad58", "#70ad8a", "#759bd2", "#a68ccc"]; +export type ProjectFolder = { id: string; name: string; color: string; paths: string[] }; +const KEY = "recordly.project-folders.v1"; +export function moveProjectFolderReferences(previous: string, next: string) { + const folders = JSON.parse(localStorage.getItem(KEY) || "[]") as ProjectFolder[]; + if (!Array.isArray(folders)) return; + const updated = folders.map((folder) => ({ + ...folder, + paths: folder.paths.map((path) => (path === previous ? next : path)), + })); + localStorage.setItem(KEY, JSON.stringify(updated)); + window.dispatchEvent(new CustomEvent("recordly-folders-changed", { detail: updated })); +} +export function useProjectFolders() { + const [folders, setFolders] = useState(() => { + try { + const value = JSON.parse(localStorage.getItem(KEY) || "[]"); + return Array.isArray(value) + ? value.filter( + (f) => + f && + typeof f.id === "string" && + typeof f.name === "string" && + /^#[0-9a-f]{6}$/i.test(f.color) && + Array.isArray(f.paths) && + f.paths.every((p: unknown) => typeof p === "string"), + ) + : []; + } catch { + return []; + } + }); + useEffect(() => { + const refresh = (event: Event) => + setFolders((event as CustomEvent).detail); + window.addEventListener("recordly-folders-changed", refresh); + return () => window.removeEventListener("recordly-folders-changed", refresh); + }, []); + const save = (next: ProjectFolder[]) => { + try { + localStorage.setItem(KEY, JSON.stringify(next)); + setFolders(next); + } catch { + toast.error("Could not save folders"); + } + }; + return { folders, save }; +} diff --git a/src/components/video-editor/layout/EditorDialogs.tsx b/src/components/video-editor/layout/EditorDialogs.tsx index 0a011138..8f8a8d26 100644 --- a/src/components/video-editor/layout/EditorDialogs.tsx +++ b/src/components/video-editor/layout/EditorDialogs.tsx @@ -10,7 +10,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import ProjectBrowserDialog, { type ProjectLibraryEntry } from "../ProjectBrowserDialog"; +import type { ProjectLibraryEntry } from "../ProjectBrowserDialog"; +import { Dashboard } from "../dashboard/Dashboard"; export type UnsavedChangesDecision = "cancel" | "discard" | "save"; @@ -37,9 +38,14 @@ interface EditorDialogsProps { projectBrowserOpen: boolean; setProjectBrowserOpen: Dispatch>; projectLibraryEntries: ProjectLibraryEntry[]; - projectBrowserAnchorRef: RefObject; + projectError: string | null; + onDashboardSignIn: () => void; + onDeleteProjects: (paths: string[]) => Promise; + onRenameProject: (path: string, name: string) => Promise; + onShareProject: (path: string) => Promise; + accountLabel?: string; handleImportMediaOrProject: () => Promise; - handleOpenProjectFromLibrary: (projectPath: string) => Promise; + handleOpenProjectFromLibrary: (projectPath: string) => Promise; nativeCaptureUnavailableModalOpen: boolean; setNativeCaptureUnavailableModalOpen: Dispatch>; } @@ -61,7 +67,12 @@ export function EditorDialogs({ projectBrowserOpen, setProjectBrowserOpen, projectLibraryEntries, - projectBrowserAnchorRef, + projectError, + onDashboardSignIn, + onDeleteProjects, + onRenameProject, + onShareProject, + accountLabel, handleImportMediaOrProject, handleOpenProjectFromLibrary, nativeCaptureUnavailableModalOpen, @@ -170,13 +181,18 @@ export function EditorDialogs({ - void handleImportMediaOrProject()} - onOpenProject={(projectPath) => void handleOpenProjectFromLibrary(projectPath)} + error={projectError} + onSignIn={onDashboardSignIn} + onDeleteProjects={onDeleteProjects} + onRenameProject={onRenameProject} + onShareProject={onShareProject} + accountLabel={accountLabel} + onImportFile={handleImportMediaOrProject} + onOpenProject={handleOpenProjectFromLibrary} /> ["t"]; exportSettings: ReturnType; exportSession: ReturnType; @@ -359,6 +360,7 @@ export function EditorExportMenu(props: Props) { {shareOpen && ( void; t: ReturnType["t"]; headerLeftControlsPaddingClass: string; project: ReturnType; @@ -100,25 +96,13 @@ export function EditorHeader(props: Props) { return (
    - -
    - - -
    -
    - - -
    + / + -
    - {isEditingProjectName ? ( -
    void handleProjectNameSubmit(event)} - className="flex w-full min-w-0 items-center gap-1" - > - {hasUnsavedChanges ? ( - - ) : null} - setProjectNameDraft(event.target.value)} - onBlur={() => { - if (!isSavingProjectName) closeProjectNameEditor(); - }} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - closeProjectNameEditor(); - } - }} - disabled={isSavingProjectName} - className="min-w-0 w-full text-sm disabled:cursor-wait" - aria-label={t("editor.project.renameInput", "Project name")} - /> - - .recordly - - - ) : ( - - )} +
    + {isEditingProjectName ? ( +
    void handleProjectNameSubmit(event)} + className="flex w-full min-w-0 items-center gap-1.5 px-1" + > + {hasUnsavedChanges ? ( + + ) : null} + setProjectNameDraft(event.target.value)} + onBlur={() => { + if ( + !isSavingProjectName && + projectNameDraft.trim() !== projectDisplayName + ) + void handleProjectNameSubmit(); + else if (!isSavingProjectName) closeProjectNameEditor(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeProjectNameEditor(); + } + }} + disabled={isSavingProjectName} + className="inline-project-name h-9 min-w-0 max-w-full text-sm font-semibold tracking-tight text-foreground/90 disabled:cursor-wait" + style={{ width: `${Math.max(12, projectNameDraft.length + 1)}ch` }} + aria-label={t("editor.project.renameInput", "Project name")} + /> + + ) : ( + + )} +
    +
    +
    + + +
    + + + } + > + requestSignIn("account")} + onDeleteProjects={openActions.handleDeleteProjects} + onRenameProject={openActions.handleRenameLibraryProject} + onShareProject={async (path) => { + if ( + path !== project.currentProjectPath && + !(await openActions.handleOpenProjectFromLibrary(path)) + ) + return; + project.setProjectBrowserOpen(false); + if (!auth.user) requestSignIn("share"); + else setShareRequestNonce((value) => value + 1); + }} + accountLabel={auth.user?.email} + handleImportMediaOrProject={openActions.handleImportMediaOrProject} + handleOpenProjectFromLibrary={openActions.handleOpenProjectFromLibrary} + nativeCaptureUnavailableModalOpen={ui.nativeCaptureUnavailableModalOpen} + setNativeCaptureUnavailableModalOpen={ui.setNativeCaptureUnavailableModalOpen} + /> + + ); if (project.loading && !project.error) return ( @@ -209,8 +237,6 @@ export function EditorShell(props: Props) { return (
    library.setOpen((open) => !open)} t={t} headerLeftControlsPaddingClass={headerLeftControlsPaddingClass} project={project} @@ -295,6 +321,8 @@ export function EditorShell(props: Props) { >
    library.setOpen((open) => !open)} onAccountClick={() => requestSignIn("account")} panelContent={ library.open ? : undefined diff --git a/src/components/video-editor/layout/EditorSidebar.tsx b/src/components/video-editor/layout/EditorSidebar.tsx index 7002bf47..edee811e 100644 --- a/src/components/video-editor/layout/EditorSidebar.tsx +++ b/src/components/video-editor/layout/EditorSidebar.tsx @@ -1,11 +1,7 @@ -import { - UserCircle, - Camera, - ClosedCaptioning, - Cursor, - Gear, - FrameCorners, -} from "@phosphor-icons/react"; +import { File } from "@/components/ui/icons"; +import { AccountAvatar } from "@/components/ui/account-avatar"; +import type { User } from "@supabase/supabase-js"; +import { Camera, ClosedCaptioning, Cursor, Gear, FrameCorners } from "@/components/ui/icons"; import { ToggleButtonGroup, ToggleButton, @@ -23,6 +19,8 @@ import { SettingsPanel } from "../SettingsPanel"; import type { EditorEffectSection } from "../types"; type Props = { + accountUser?: User | null; + onToggleVideos: () => void; panelContent?: ReactNode; onAccountClick?: () => void; t: ReturnType["t"]; @@ -33,6 +31,8 @@ type Props = { export function EditorSidebar({ t, + accountUser, + onToggleVideos, activeSection, setActiveSection, settingsPanelProps, @@ -80,12 +80,19 @@ export function EditorSidebar({ className="w-full items-center gap-2" selectionMode="single" disallowEmptySelection - selectedKeys={panelContent ? [] : [activeSection]} + selectedKeys={[panelContent ? "videos" : activeSection]} onSelectionChange={(keys) => { const key = Array.from(keys)[0]; - if (key) setActiveSection(key as EditorEffectSection); + if (key === "videos") onToggleVideos(); + else if (key) setActiveSection(key as EditorEffectSection); }} > + + + + + Videos + {sections.map((section) => ( - + {section.label} @@ -108,7 +122,7 @@ export function EditorSidebar({ aria-label="Recordly account" onPress={onAccountClick} > - + Account diff --git a/src/components/video-editor/library/RecordingLibraryPanel.tsx b/src/components/video-editor/library/RecordingLibraryPanel.tsx index 222604a1..4aa70f95 100644 --- a/src/components/video-editor/library/RecordingLibraryPanel.tsx +++ b/src/components/video-editor/library/RecordingLibraryPanel.tsx @@ -9,7 +9,7 @@ import { ArrowCounterClockwise, DotsThree, FolderOpen, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { Button } from "@/components/ui/button"; import { RECORDING_DRAG_TYPE } from "@/types/recordingLibrary"; import { cn } from "@/lib/utils"; diff --git a/src/components/video-editor/library/RecordingThumbnail.tsx b/src/components/video-editor/library/RecordingThumbnail.tsx index 8eddce50..e883666f 100644 --- a/src/components/video-editor/library/RecordingThumbnail.tsx +++ b/src/components/video-editor/library/RecordingThumbnail.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { FilmStrip } from "@phosphor-icons/react"; +import { FilmStrip } from "@/components/ui/icons"; import type { RecordingLibraryEntry } from "@/types/recordingLibrary"; export function RecordingThumbnail({ entry }: { entry: RecordingLibraryEntry }) { diff --git a/src/components/video-editor/project/useProjectLibraryController.ts b/src/components/video-editor/project/useProjectLibraryController.ts index 92a6578b..8b893683 100644 --- a/src/components/video-editor/project/useProjectLibraryController.ts +++ b/src/components/video-editor/project/useProjectLibraryController.ts @@ -112,8 +112,8 @@ export function useProjectLibraryController({ } const canvas = document.createElement("canvas"); - const targetWidth = 320; - const targetHeight = 180; + const targetWidth = 1600; + const targetHeight = 1200; canvas.width = targetWidth; canvas.height = targetHeight; diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 9d740492..45e7b4d0 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, } from "react"; +import type { useProjectSaveActions } from "./useProjectSaveActions"; import { toast } from "@/components/ui/toast"; import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; import type { useAppearanceState } from "../state/useAppearanceState"; @@ -26,7 +27,7 @@ type UseProjectOpenActionsInput = { setDuration: Set; applyLoadedProject: (candidate: unknown, path?: string | null) => Promise; openUnsavedChangesDialog: (actionLabel: string) => Promise<"save" | "discard" | "cancel">; - saveProject: (forceSaveAs: boolean) => Promise; + saveProject: ReturnType["saveProject"]; refreshProjectLibrary: () => Promise; resetSourceScopedEditorState: () => void; applySessionPresentation: (session: null) => void; @@ -79,7 +80,7 @@ export function useProjectOpenActions({ } project.setProjectBrowserOpen(false); await refreshProjectLibrary(); - toast.success(`Project loaded from ${result.path}`); + return true; } catch (error) { project.setError( `Could not load project: ${error instanceof Error ? error.message : String(error)}`, @@ -172,14 +173,31 @@ export function useProjectOpenActions({ refreshProjectLibrary, ]); - const handleOpenProjectBrowser = useCallback(() => { + const handleOpenProjectBrowser = useCallback(async () => { if (project.projectBrowserOpen) { project.setProjectBrowserOpen(false); return; } + videoPlaybackRef.current?.pause(); + setIsPlaying(false); + if ( + project.videoPath && + !project.error && + !(await saveProject(false, { remountPreviewAfterSave: false })) + ) + return; project.setProjectBrowserOpen(true); void refreshProjectLibrary(); - }, [project.projectBrowserOpen, project.setProjectBrowserOpen, refreshProjectLibrary]); + }, [ + project.projectBrowserOpen, + project.setProjectBrowserOpen, + refreshProjectLibrary, + videoPlaybackRef, + setIsPlaying, + saveProject, + project.videoPath, + project.error, + ]); useEffect(() => { const removeLoad = window.electronAPI.onMenuLoadProject( @@ -194,5 +212,32 @@ export function useProjectOpenActions({ }; }, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]); - return { handleOpenProjectFromLibrary, handleImportMediaOrProject, handleOpenProjectBrowser }; + const handleDeleteProjects = useCallback( + async (paths: string[]) => { + const result = await window.electronAPI.trashProjectFiles(paths); + if (project.currentProjectPath && result.deleted.includes(project.currentProjectPath)) { + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + } + await refreshProjectLibrary(); + if (result.errors.length) toast.error(result.errors.join("\n")); + return result.deleted; + }, + [project, refreshProjectLibrary], + ); + const handleRenameLibraryProject = async (path: string, name: string) => { + const result = await window.electronAPI.renameLibraryProject(path, name); + if (!result.success || !result.path) throw new Error(result.error || "Could not rename project"); + if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path); + await refreshProjectLibrary(); + return result.path; + }; + + return { + handleRenameLibraryProject, + handleOpenProjectFromLibrary, + handleImportMediaOrProject, + handleOpenProjectBrowser, + handleDeleteProjects, + }; } diff --git a/src/components/video-editor/project/useProjectSaveActions.ts b/src/components/video-editor/project/useProjectSaveActions.ts index e2d002d8..504f491f 100644 --- a/src/components/video-editor/project/useProjectSaveActions.ts +++ b/src/components/video-editor/project/useProjectSaveActions.ts @@ -1,10 +1,12 @@ +import { moveProjectFolderReferences } from "../dashboard/useProjectFolders"; +import { moveProjectShareLink } from "../cloud/projectShareLinks"; import { type RefObject, useCallback, useEffect, useRef } from "react"; import { toast } from "@/components/ui/toast"; import { createProjectData, type EditorProjectData } from "../projectPersistence"; import type { useProjectState } from "../state/useProjectState"; import { cloneStructured, getErrorMessage } from "../videoEditorUtils"; -const PROJECT_AUTOSAVE_DELAY_MS = 1_000; +const PROJECT_AUTOSAVE_DELAY_MS = 750; type SaveProjectOptions = { silent?: boolean; @@ -57,6 +59,11 @@ export function useProjectSaveActions({ setIsSavingProjectName, setProjectBrowserOpen, } = project; + const savingNameRef = useRef(false); + const activePathRef = useRef(currentProjectPath); + activePathRef.current = currentProjectPath; + const activeSourceRef = useRef(currentSourcePath); + activeSourceRef.current = currentSourcePath; const autosaveTimeoutRef = useRef(null); const saveQueueRef = useRef>(Promise.resolve()); const clearPendingAutosave = useCallback(() => { @@ -74,7 +81,9 @@ export function useProjectSaveActions({ const saveProject = useCallback( async (forceSaveAs: boolean, options?: SaveProjectOptions) => { clearPendingAutosave(); + if (forceSaveAs) return openProjectSaveDialog(projectDisplayName || "Untitled Project"); return queueSave(async () => { + if (activeSourceRef.current !== currentSourcePath) return false; if (!currentSourcePath) { if (!options?.silent) toast.error("No video loaded"); return false; @@ -97,40 +106,35 @@ export function useProjectSaveActions({ .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`; - let targetPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined); - - if (!forceSaveAs && !targetPath) { - const activeProject = await window.electronAPI.loadCurrentProjectFile(); - if (activeProject.success && activeProject.path) { - targetPath = activeProject.path; - setCurrentProjectPath(activeProject.path); - } - } - if (forceSaveAs || !targetPath) { - if (options?.silent) return false; - return await openProjectSaveDialog(projectDisplayName || fileNameBase); - } + const targetPath = forceSaveAs + ? undefined + : (activePathRef.current ?? undefined); const thumbnail = captureThumbnail - ? await captureProjectThumbnail() + ? ((await captureProjectThumbnail()) ?? undefined) : undefined; - const result = await window.electronAPI.saveProjectFile( - projectData, - fileNameBase, - targetPath, - thumbnail, - ); + const result = !targetPath + ? await window.electronAPI.createProjectFile(projectData, thumbnail) + : await window.electronAPI.saveProjectFile( + projectData, + fileNameBase, + targetPath, + thumbnail, + ); if (result.canceled) { if (!options?.silent) toast.info("Project save canceled"); return false; } if (!result.success) { - if (!options?.silent) - toast.error(result.message || "Failed to save project"); + toast.error(result.message || "Failed to save project"); return false; } - if (result.path) setCurrentProjectPath(result.path); + if (activeSourceRef.current !== currentSourcePath) return true; + if (result.path) { + activePathRef.current = result.path; + setCurrentProjectPath(result.path); + } setLastSavedSnapshot( cloneStructured( createProjectData( @@ -141,8 +145,11 @@ export function useProjectSaveActions({ ), ); if (refreshLibrary) await refreshProjectLibrary(); - if (!options?.silent) toast.success(`Project saved to ${result.path}`); + return true; + } catch (error) { + toast.error(`Could not save project: ${getErrorMessage(error)}`); + return false; } finally { if (remount) remountPreview(); } @@ -154,7 +161,6 @@ export function useProjectSaveActions({ currentSourcePath, currentProjectSnapshot, currentPersistedEditorState, - currentProjectPath, lastSavedSnapshot, setCurrentProjectPath, setLastSavedSnapshot, @@ -174,7 +180,12 @@ export function useProjectSaveActions({ [saveProject], ); useEffect(() => { - if (!currentProjectPath || !hasUnsavedChanges) { + if ( + project.projectBrowserOpen || + project.loading || + !currentSourcePath || + !hasUnsavedChanges + ) { clearPendingAutosave(); return; } @@ -188,7 +199,14 @@ export function useProjectSaveActions({ }); }, PROJECT_AUTOSAVE_DELAY_MS); return clearPendingAutosave; - }, [clearPendingAutosave, currentProjectPath, hasUnsavedChanges, saveProject]); + }, [ + clearPendingAutosave, + hasUnsavedChanges, + saveProject, + project.projectBrowserOpen, + project.loading, + currentSourcePath, + ]); useEffect(() => clearPendingAutosave, [clearPendingAutosave]); const saveProjectWithName = useCallback( @@ -202,47 +220,73 @@ export function useProjectSaveActions({ toast.error("No video loaded"); return false; } - try { - const projectData = - currentProjectSnapshot?.videoPath === currentSourcePath - ? currentProjectSnapshot - : createProjectData( - currentSourcePath, - currentPersistedEditorState, - lastSavedSnapshot?.projectId ?? null, + clearPendingAutosave(); + return queueSave(async () => { + if (activeSourceRef.current !== currentSourcePath) return false; + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData( + currentSourcePath, + currentPersistedEditorState, + lastSavedSnapshot?.projectId ?? null, + ); + const previousPath = activePathRef.current; + const result = await window.electronAPI.saveProjectFileNamed( + projectData, + trimmedName, + await captureProjectThumbnail(), + mode, + ); + if (result.canceled) { + toast.info("Project save canceled"); + return false; + } + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return false; + } + if (activeSourceRef.current !== currentSourcePath) return true; + if ( + mode === "rename" && + previousPath && + result.path && + previousPath !== result.path + ) { + try { + moveProjectFolderReferences(previousPath, result.path); + moveProjectShareLink(previousPath, result.path); + } catch { + toast.error( + "Project renamed, but library preferences could not be updated", ); - const result = await window.electronAPI.saveProjectFileNamed( - projectData, - trimmedName, - await captureProjectThumbnail(), - mode, - ); - if (result.canceled) { - toast.info("Project save canceled"); - return false; - } - if (!result.success) { - toast.error(result.message || "Failed to save project"); - return false; - } - if (result.path) setCurrentProjectPath(result.path); - setLastSavedSnapshot( - cloneStructured( - createProjectData( - projectData.videoPath, - projectData.editor, - result.projectId ?? projectData.projectId ?? null, + } + } + if (result.path) { + activePathRef.current = result.path; + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot( + cloneStructured( + createProjectData( + projectData.videoPath, + projectData.editor, + result.projectId ?? projectData.projectId ?? null, + ), ), - ), - ); - await refreshProjectLibrary(); - toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); - return true; - } finally { - remountPreview(); - } + ); + await refreshProjectLibrary(); + + return true; + } finally { + remountPreview(); + } + }); }, [ + clearPendingAutosave, + queueSave, currentSourcePath, currentProjectSnapshot, currentPersistedEditorState, @@ -296,7 +340,9 @@ export function useProjectSaveActions({ async (event?: React.FormEvent) => { event?.preventDefault(); const name = projectNameDraft.trim(); - if (!name) return closeProjectNameEditor(); + if (savingNameRef.current) return; + if (!name || name === projectDisplayName) return closeProjectNameEditor(); + savingNameRef.current = true; setIsSavingProjectName(true); let saved = false; try { @@ -305,6 +351,7 @@ export function useProjectSaveActions({ toast.error(getErrorMessage(error)); } finally { setIsSavingProjectName(false); + savingNameRef.current = false; } if (saved) setIsEditingProjectName(false); else { @@ -314,6 +361,7 @@ export function useProjectSaveActions({ }, [ projectNameDraft, + projectDisplayName, setIsSavingProjectName, setIsEditingProjectName, projectNameInputRef, diff --git a/src/components/video-editor/project/useProjectSnapshotModel.ts b/src/components/video-editor/project/useProjectSnapshotModel.ts index e138cad1..aab65ffa 100644 --- a/src/components/video-editor/project/useProjectSnapshotModel.ts +++ b/src/components/video-editor/project/useProjectSnapshotModel.ts @@ -42,12 +42,10 @@ export function useProjectSnapshotModel({ ); const projectDisplayName = useMemo(() => { const fileName = - project.currentProjectPath?.split(/[\\/]/).pop() ?? - currentSourcePath?.split(/[\\/]/).pop() ?? - ""; + project.currentProjectPath?.split(/[\\/]/).pop() ?? "Untitled Project"; return ( fileName.replace(/\.recordly$/i, "").replace(/\.[^.]+$/, "") || - t("editor.project.untitled", "Untitled") + t("editor.project.untitled", "Untitled Project") ); }, [project.currentProjectPath, currentSourcePath, t]); diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index c8790fd2..c7690d4a 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -5,7 +5,7 @@ import { MusicNotes as Music, Scissors, SpeakerX, -} from "@phosphor-icons/react"; +} from "@/components/ui/icons"; import { ClipFilmstrip } from "./components/filmstrip/ClipFilmstrip"; import type { Span, GetSpanFromDragEvent, GetSpanFromResizeEvent } from "dnd-timeline"; import { useItem, useTimelineContext } from "dnd-timeline"; diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 3a2087d9..47252a86 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,4 +1,4 @@ -import { Plus } from "@phosphor-icons/react"; +import { Plus } from "@/components/ui/icons"; import type { Span } from "dnd-timeline"; import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import type { diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index 23ef6992..824cd3b6 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -1,5 +1,5 @@ import { useTimelinePresentation } from "../../core/TimelinePresentation"; -import { Plus } from "@phosphor-icons/react"; +import { Plus } from "@/components/ui/icons"; import { useTimelineContext } from "dnd-timeline"; import { type MouseEvent, diff --git a/src/index.css b/src/index.css index 9615a725..e4ee7cb9 100644 --- a/src/index.css +++ b/src/index.css @@ -97,14 +97,6 @@ .editor-playback { container-type: inline-size; } -@media (max-width: 1000px) { - .editor-header { - grid-template-columns: minmax(0, 1fr) minmax(0, 0.4fr) minmax(0, 1fr); - } - .project-file-extension { - display: none; - } -} @container (max-width: 760px) { .editor-playback-tools { grid-row: 2; @@ -174,3 +166,34 @@ animation: none; } } + +/* Keep dashboard focus visible without the oversized default halo. */ +.dashboard-surface :is(button, input, [role="combobox"]):focus-visible, +.dashboard-surface [data-focus-visible="true"] { + outline: 1px solid var(--accent); + outline-offset: 2px; + box-shadow: none; +} +.dashboard-surface .folder-row > button { + background: transparent; +} + +.inline-project-name { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + outline: none; + padding: 0; + color: inherit; +} +.inline-project-name:focus { outline: none; box-shadow: none; } + +.dashboard-shell { + background-color: color-mix(in srgb, var(--background) 92%, var(--foreground) 8%); +} + +.dark .dashboard-shell { background-color: var(--background); } +.dark .dashboard-shell > .card { background-color: color-mix(in srgb, var(--background) 92%, var(--foreground) 8%); } + +.dashboard-settings .custom-scrollbar { padding-inline: 0; scrollbar-gutter: auto !important; } diff --git a/tests/ui/bridge.ts b/tests/ui/bridge.ts index e5a3b7fc..8ebe88ab 100644 --- a/tests/ui/bridge.ts +++ b/tests/ui/bridge.ts @@ -31,6 +31,37 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m getAppVersion: async () => "1.4.0", getAnnouncements: async () => ({ success: true, announcements: [] }), loadCurrentProjectFile: async () => ({ success: false }), + createProjectFile: async () => { + document.documentElement.dataset.projectCreates = String( + Number(document.documentElement.dataset.projectCreates || 0) + 1, + ); + return { + success: true, + path: "/projects/Untitled Project.recordly", + projectId: "test-project", + }; + }, + saveProjectFile: async ( + _data: unknown, + _name: string, + projectPath: string, + thumbnail?: string, + ) => { + document.documentElement.dataset.projectSaves = String( + Number(document.documentElement.dataset.projectSaves || 0) + 1, + ); + if (thumbnail) + document.documentElement.dataset.savedThumbnail = thumbnail.slice(0, 22); + return { success: true, path: projectPath, projectId: "test-project" }; + }, + showRecordingHud: async () => { + document.documentElement.dataset.hudOpened = "true"; + }, + trashProjectFiles: async (paths: string[]) => ({ + success: true, + deleted: paths, + errors: [], + }), getCurrentRecordingSession: async () => ({ success: true, session: null }), getCurrentVideoPath: async () => ({ success: true, @@ -44,6 +75,9 @@ export async function installDesktopBridge(page: Page, videoFixture = "preview.m finishRecordingImport: success, setCurrentRecordingSession: success, setHasUnsavedChanges: success, + onAuthCallbackUrl: subscribe, + getPendingAuthCallbackUrl: async () => null, + ackAuthCallbackUrl: success, onMenuSaveProject: subscribe, onMenuSaveProjectAs: subscribe, onMenuLoadProject: subscribe, diff --git a/tests/ui/editor-layout.spec.ts b/tests/ui/editor-layout.spec.ts index 6bfcd462..bdb9f675 100644 --- a/tests/ui/editor-layout.spec.ts +++ b/tests/ui/editor-layout.spec.ts @@ -42,9 +42,7 @@ test("advanced controls preserve values and remember each section's view", async await page.screenshot({ path: "test-results/editor-color-picker.png", animations: "disabled" }); }); -test("header stays centered with long names, native chrome and compact windows", async ({ - page, -}) => { +test("header breadcrumb fits long names, native chrome and compact windows", async ({ page }) => { await installDesktopBridge(page); await page.goto("/?windowType=editor"); await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible({ @@ -53,7 +51,7 @@ test("header stays centered with long names, native chrome and compact windows", await page.getByRole("button", { name: "Rename project" }).click(); await page .getByRole("textbox", { name: "Project name" }) - .fill("A very long project title that should stay centered and never cover the toolbar"); + .fill("A very long project title that should truncate and never cover the toolbar"); // Validate the editing state as well as the display state. for (const width of [1440, 1280, 800]) { await page.setViewportSize({ width, height: 800 }); @@ -85,8 +83,8 @@ test("header stays centered with long names, native chrome and compact windows", ), ); const [left, center, right] = boxes; - expect(Math.abs(center!.x + center!.width / 2 - width / 2)).toBeLessThan(1); - expect(left!.x + left!.width).toBeLessThanOrEqual(center!.x); + expect(center!.x).toBeGreaterThan(left!.x); + expect(left!.x + left!.width).toBeLessThanOrEqual(right!.x); expect(center!.x + center!.width).toBeLessThanOrEqual(right!.x); const buttons = await page.locator(".editor-playback button").evaluateAll((nodes) => nodes @@ -117,3 +115,28 @@ test("header stays centered with long names, native chrome and compact windows", await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible(); }); + +test("header name edits in place and saves on blur without a boxed input", async ({ page }) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.saveProjectFileNamed = async (_data, name) => ({ + success: true, + path: `/projects/${name}.recordly`, + }); + }); + await page.goto("/?windowType=editor"); + await expect(page.getByRole("button", { name: "Rename project" })).toContainText( + "Untitled Project", + ); + await page.getByRole("button", { name: "Rename project" }).click(); + const input = page.getByRole("textbox", { name: "Project name" }); + await expect(input).toHaveCSS("box-shadow", "none"); + await expect(input).toHaveCSS("border-top-width", "0px"); + await input.fill("Launch demo"); + await input.press("Tab"); + await expect(page.getByRole("button", { name: "Rename project" })).toContainText("Launch demo"); + await page.getByRole("button", { name: "Rename project" }).click(); + await input.fill("Discard this"); + await input.press("Escape"); + await expect(page.getByRole("button", { name: "Rename project" })).toContainText("Launch demo"); +}); diff --git a/tests/ui/hud-layout.spec.ts b/tests/ui/hud-layout.spec.ts new file mode 100644 index 00000000..51e03146 --- /dev/null +++ b/tests/ui/hud-layout.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; +import { installDesktopBridge } from "./bridge"; + +test("HUD dividers are vertically centered", async ({ page }) => { + await installDesktopBridge(page); + await page.goto("/?windowType=hud-overlay"); + const dividers = page.locator(".separator--vertical"); + await expect(dividers.first()).toBeVisible(); + const offsets = await dividers.evaluateAll((elements) => + elements.map((element) => { + const bounds = element.getBoundingClientRect(); + const parent = element.parentElement!.getBoundingClientRect(); + return Math.abs(bounds.y + bounds.height / 2 - parent.y - parent.height / 2); + }), + ); + for (const offset of offsets) expect(offset).toBeLessThanOrEqual(1); + await page.screenshot({ path: "test-results/hud-dividers.png", animations: "disabled" }); +}); + +test("recording HUD uses uniform controls and a readable timer", async ({ page }) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.onRecordingStateChanged = (callback) => { + const listener = () => callback({ recording: true }); + window.addEventListener("test-recording-started", listener); + return () => window.removeEventListener("test-recording-started", listener); + }; + }); + await page.goto("/?windowType=hud-overlay"); + await expect(page.locator(".separator--vertical").first()).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new Event("test-recording-started"))); + const controls = page.getByRole("group", { name: "Recording controls" }); + await expect(controls).toBeVisible(); + await expect(controls.getByRole("status")).toContainText("00:00"); + const sizes = await controls.getByRole("button").evaluateAll((buttons) => + buttons.map((button) => { + return [(button as HTMLElement).offsetWidth, (button as HTMLElement).offsetHeight]; + }), + ); + for (const size of sizes) expect(size).toEqual([36, 36]); + await page.screenshot({ path: "test-results/hud-recording.png" }); +}); diff --git a/tests/ui/project-dashboard.spec.ts b/tests/ui/project-dashboard.spec.ts new file mode 100644 index 00000000..b7ccfadb --- /dev/null +++ b/tests/ui/project-dashboard.spec.ts @@ -0,0 +1,396 @@ +import { expect, test } from "@playwright/test"; +import { installDesktopBridge } from "./bridge"; + +test("home dashboard searches, sorts, opens projects and returns to the editor", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + const entries = [ + { + path: "/projects/launch.recordly", + name: "Launch video", + updatedAt: 1758000000000, + thumbnailPath: null, + isCurrent: true, + isInProjectsDirectory: true, + }, + { + path: "/projects/demo.recordly", + name: "App walkthrough", + updatedAt: 1759000000000, + thumbnailPath: `${location.origin}/tests/ui/fixtures/recording-thumbnail.jpg`, + isCurrent: false, + isInProjectsDirectory: true, + }, + { + path: "/projects/tutorial.recordly", + name: "Getting started", + updatedAt: 1757000000000, + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: true, + }, + ]; + window.electronAPI.listProjectFiles = async () => ({ + success: true, + projects: [], + entries, + }); + window.electronAPI.openProjectFileAtPath = async (path) => { + document.documentElement.dataset.openedProject = path; + return { success: false, canceled: true }; + }; + }); + await page.goto("/?windowType=editor"); + await page.getByRole("button", { name: "Open projects", exact: true }).click(); + const home = page.getByRole("dialog", { name: "Projects dashboard", exact: true }); + await expect(home).toBeVisible(); + const cards = home.getByRole("list", { name: "Your projects" }).locator("li > button"); + await expect(cards).toHaveCount(3); + await expect(cards.first()).toHaveAccessibleName("App walkthrough"); + await home.getByRole("button", { name: "Sort projects" }).click(); + await page.getByRole("menuitem", { name: "Name", exact: true }).click(); + await expect(cards.nth(1)).toHaveAccessibleName("Getting started"); + await home.getByRole("textbox", { name: "Search projects" }).fill("launch"); + await expect(cards).toHaveCount(1); + await home.getByRole("textbox", { name: "Search projects" }).fill("missing"); + await expect(home.getByText("No matching projects")).toBeVisible(); + await home.getByRole("button", { name: "Clear search" }).click(); + await home.getByRole("button", { name: "App walkthrough", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Project details" })).toHaveCount(0); + await expect(page.locator("html")).toHaveAttribute( + "data-opened-project", + "/projects/demo.recordly", + ); + await expect(home).toBeVisible(); + await home.getByRole("button", { name: "New folder", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "New folder", exact: true })).toHaveCount(0); + await home.getByRole("button", { name: "Untitled folder", exact: true }).dblclick(); + await home.getByRole("textbox", { name: "Folder name" }).fill("Tutorials"); + await page.keyboard.press("Enter"); + await home.getByRole("button", { name: "Change color for Tutorials", exact: true }).click(); + await page.getByRole("textbox", { name: "Hex color", exact: true }).fill("#123abc"); + await page.keyboard.press("Tab"); + await page.getByRole("button", { name: "Save custom color", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Remove custom color", exact: true }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await home.getByRole("button", { name: "Projects", exact: true }).click(); + await expect(home.locator('[aria-label="Local profile"]')).toHaveCount(3); + + await home.getByRole("button", { name: "Options for App walkthrough", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "No folder", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await home.getByRole("button", { name: "Add folder to App walkthrough", exact: true }).click(); + await page.getByRole("menuitem", { name: "Tutorials", exact: true }).click(); + await expect( + home.getByRole("button", { name: "Remove App walkthrough from Tutorials", exact: true }), + ).toContainText("Tutorials"); + await home.getByRole("button", { name: "Tutorials", exact: true }).click(); + await expect(cards).toHaveCount(1); + await expect(cards.first()).toHaveAccessibleName("App walkthrough"); + await home.getByRole("button", { name: "Projects", exact: true }).click(); + await home.getByRole("button", { name: "Last 7 days", exact: true }).click(); + await expect(cards).toHaveCount(0); + await home.getByRole("button", { name: "All", exact: true }).click(); + await expect(cards).toHaveCount(3); + await home.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(home.getByRole("region", { name: "Dashboard settings" })).toBeVisible(); + await home.getByRole("button", { name: "Projects", exact: true }).click(); + await home.getByRole("button", { name: "New", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute("data-hud-opened", "true"); + await home.getByRole("button", { name: "Select projects to delete" }).click(); + await cards.first().click(); + await expect(cards.first()).toHaveAttribute("aria-pressed", "true"); + await home.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Delete 1 project?" })).toBeVisible(); + await page + .getByRole("dialog", { name: "Delete 1 project?" }) + .getByRole("button", { name: "Cancel", exact: true }) + .click(); + await home.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.screenshot({ path: "test-results/project-dashboard.png", animations: "disabled" }); + await page.evaluate(() => document.documentElement.classList.add("dark")); + await page.screenshot({ + path: "test-results/project-dashboard-dark.png", + animations: "disabled", + }); + await page.evaluate(() => document.documentElement.classList.remove("dark")); + await page.setViewportSize({ width: 800, height: 800 }); + await expect(home.getByRole("button", { name: "Import", exact: true })).toBeInViewport(); + await page.screenshot({ + path: "test-results/project-dashboard-compact.png", + animations: "disabled", + }); + await home.getByRole("button", { name: "Launch video", exact: true }).click(); + await expect(home).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible(); +}); + +test("home dashboard explains an empty library", async ({ page }) => { + await installDesktopBridge(page); + await page.goto("/?windowType=editor"); + await page.getByRole("button", { name: "Open projects", exact: true }).click(); + await expect(page.getByText("No projects yet")).toBeVisible(); + await page.getByRole("button", { name: "Back to editor" }).click(); + await expect(page.getByRole("button", { name: "Rename project" })).toBeVisible(); +}); + +test("autosave creates one untitled project, stays idle without edits, and refreshes its preview on exit without a saved toast", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.goto("/?windowType=editor"); + await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1"); + await expect(page.getByRole("button", { name: "Rename project" })).toContainText( + "Untitled Project", + ); + // Observe more than two debounce periods: idle must not perform periodic saves. + const before = await page.locator("html").getAttribute("data-project-saves"); + await page.waitForTimeout(1800); + await expect(page.locator("html")).toHaveAttribute("data-project-creates", "1"); + expect(await page.locator("html").getAttribute("data-project-saves")).toBe(before); + await page.getByRole("button", { name: "Open projects", exact: true }).click(); + await expect(page.getByRole("dialog", { name: "Projects dashboard" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute( + "data-saved-thumbnail", + /^data:image\/png;base64,/, + ); + await expect(page.getByText(/Project saved/)).toHaveCount(0); +}); + +test("deletion refreshes the grid and keeps unselected projects", async ({ page }) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + let entries = ["Keep", "Delete"].map((name) => ({ + path: `/projects/${name}.recordly`, + name, + updatedAt: Date.now(), + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: true, + })); + window.electronAPI.listProjectFiles = async () => ({ success: true, entries }); + window.electronAPI.trashProjectFiles = async (paths) => { + entries = entries.filter((e) => !paths.includes(e.path)); + return { success: true, deleted: paths, errors: [] }; + }; + }); + await page.goto("/?windowType=editor"); + await page.getByRole("button", { name: "Open projects", exact: true }).click(); + const home = page.getByRole("dialog", { name: "Projects dashboard" }); + await home.getByRole("button", { name: "Select projects to delete" }).click(); + await home.getByRole("list").getByRole("button", { name: "Delete", exact: true }).click(); + await home + .getByRole("button", { name: "Delete", exact: true }) + .filter({ hasNot: page.locator("img") }) + .first() + .click(); + await page + .getByRole("dialog", { name: "Delete 1 project?" }) + .getByRole("button", { name: "Move to Trash" }) + .click(); + await expect( + home.getByRole("list").getByRole("button", { name: "Delete", exact: true }), + ).toHaveCount(0); + await expect(home.getByRole("button", { name: "Keep", exact: true })).toBeVisible(); +}); + +test("cards rename inline, preserve folder chips and use existing share links", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + let entry = { + path: "/projects/demo.recordly", + name: "Demo", + updatedAt: Date.now(), + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: true, + }; + localStorage.setItem( + "recordly.project-folders.v1", + JSON.stringify([{ id: "folder", name: "Work", color: "#123abc", paths: [entry.path] }]), + ); + localStorage.setItem( + "recordly.project-share-links.v1", + JSON.stringify({ [entry.path]: "https://example.com/shared/demo" }), + ); + window.electronAPI.listProjectFiles = async () => ({ success: true, entries: [entry] }); + window.electronAPI.renameLibraryProject = async (_path, name) => { + entry = { ...entry, path: `/projects/${name}.recordly`, name }; + return { success: true, path: entry.path }; + }; + window.electronAPI.openExternalUrl = async (url) => { + document.documentElement.dataset.openedUrl = url; + return { success: true }; + }; + }); + await page.goto("/?windowType=editor"); + await page.getByRole("button", { name: "Open projects", exact: true }).click(); + const home = page.getByRole("dialog", { name: "Projects dashboard" }); + await expect(home.getByRole("button", { name: "Remove Demo from Work" })).toContainText("Work"); + await home.getByRole("button", { name: "Options for Demo" }).click(); + await page.getByRole("menuitem", { name: "View in web", exact: true }).click(); + await expect(page.locator("html")).toHaveAttribute( + "data-opened-url", + "https://example.com/shared/demo", + ); + await home.getByRole("button", { name: "Options for Demo" }).click(); + await page.getByRole("menuitem", { name: "Rename", exact: true }).click(); + await home.getByRole("textbox", { name: "Project name" }).fill("Renamed"); + await page.keyboard.press("Enter"); + await expect(home.getByRole("button", { name: "Renamed", exact: true })).toBeVisible(); + await expect(home.getByRole("button", { name: "Remove Renamed from Work" })).toContainText( + "Work", + ); + await home.getByRole("button", { name: "Options for Renamed" }).click(); + await expect(page.getByRole("menuitem", { name: "View in web", exact: true })).toBeVisible(); +}); + +test("dashboard supports creation sort, independent folders, shared settings and precise captions", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + localStorage.setItem( + "recordly.project-folders.v1", + JSON.stringify([ + { id: "one", name: "Work", color: "#123abc", paths: ["/old.recordly"] }, + { id: "two", name: "Personal", color: "#123abc", paths: [] }, + ]), + ); + window.electronAPI.listProjectFiles = async () => ({ + success: true, + entries: [ + { + path: "/old.recordly", + name: "Old", + updatedAt: 300, + createdAt: 100, + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: true, + }, + { + path: "/new.recordly", + name: "Newer", + updatedAt: 200, + createdAt: 200, + thumbnailPath: null, + isCurrent: false, + isInProjectsDirectory: true, + }, + ], + }); + }); + await page.goto("/?windowType=editor"); + await page.getByRole("radio", { name: "Videos", exact: true }).click(); + await expect(page.getByRole("complementary", { name: "Videos" })).toBeVisible(); + await page.getByRole("button", { name: "Open projects", exact: true }).click(); + const home = page.getByRole("dialog", { name: "Projects dashboard" }); + await home.getByRole("button", { name: "Next announcement" }).click(); + await expect( + home.getByRole("img", { name: "Announcement banner placeholder 2" }), + ).toBeVisible(); + await home.getByRole("button", { name: "Sort projects" }).click(); + await page.getByRole("menuitem", { name: "Last created", exact: true }).click(); + await expect( + home.getByRole("list", { name: "Your projects" }).locator("li > button").first(), + ).toHaveAccessibleName("Newer"); + await home.getByRole("button", { name: "Add folder to Old" }).click(); + await page.getByRole("menuitem", { name: "Personal", exact: true }).click(); + await expect(home.getByRole("button", { name: "Remove Old from Work" })).toBeVisible(); + await expect(home.getByRole("button", { name: "Remove Old from Personal" })).toBeVisible(); + const membership = await page.evaluate(() => + JSON.parse(localStorage.getItem("recordly.project-folders.v1") || "[]"), + ); + expect( + membership.every((folder: { paths: string[] }) => folder.paths.includes("/old.recordly")), + ).toBe(true); + const heights = await home + .getByRole("list", { name: "Your projects" }) + .locator("li") + .evaluateAll((cards) => + cards.map((card) => [ + card.querySelector('[aria-label="Local profile"]')!.getBoundingClientRect().height, + card.querySelector("[data-project-caption]")!.getBoundingClientRect().height, + ]), + ); + for (const [avatar, caption] of heights) expect(avatar).toBe(caption); + const avatar = home.locator('[aria-label="Local profile"]').first(); + const circle = await avatar.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height, radius: parseFloat(style.borderRadius) }; + }); + expect(circle.width).toBe(circle.height); + expect(circle.radius).toBeGreaterThanOrEqual(circle.width / 2); + await expect(avatar).toHaveText("LP"); + await home.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0); + await expect(home.getByRole("button", { name: "Sort projects" })).toHaveCount(0); + await expect(home.getByRole("row", { name: "Dark", exact: true })).toBeVisible(); + await expect(home.getByRole("switch", { name: "Experimental updates" })).toBeVisible(); + await expect(home.getByRole("switch", { name: "Connect Zooms" })).toBeVisible(); + await page.screenshot({ path: "test-results/dashboard-settings.png" }); +}); + +test("Solar navigation selection, circular initials, and Raw sources are consistent", async ({ + page, +}) => { + await installDesktopBridge(page); + await page.addInitScript(() => { + window.electronAPI.listRecordings = async (includeSources) => ({ + success: true, + value: (includeSources + ? ["screen.mp4", "screen.webcam.mp4", "screen.mic.wav"] + : ["screen.mp4"] + ).map((name) => ({ + path: `/recordings/${name}`, + name, + createdAt: Date.now(), + bytes: 1048576, + url: `${location.origin}/tests/ui/fixtures/preview.mp4`, + })), + }); + window.electronAPI.revealInFolder = async (path) => { + document.documentElement.dataset.revealed = path; + return { success: true }; + }; + window.electronAPI.getRecordingThumbnail = async () => ({ + success: false, + error: "No thumbnail", + }); + }); + await page.goto("/?windowType=editor"); + const scene = page.getByRole("radio", { name: "Scene", exact: true }); + const videos = page.getByRole("radio", { name: "Videos", exact: true }); + await expect(scene).toBeChecked(); + await expect(scene.locator("svg")).toHaveAttribute("data-icon-style", "bold"); + await videos.click(); + await expect(videos).toBeChecked(); + await expect(videos.locator("svg")).toHaveAttribute("data-icon-style", "bold"); + await expect(scene.locator("svg")).toHaveAttribute("data-icon-style", "linear"); + await scene.click(); + await expect(scene).toBeChecked(); + await expect(videos).not.toBeChecked(); + const homeButton = page.getByRole("button", { name: "Open projects", exact: true }); + await expect(homeButton.locator("svg")).toHaveAttribute("data-icon-style", "bold"); + await homeButton.click(); + const home = page.getByRole("dialog", { name: "Projects dashboard" }); + await home.getByRole("button", { name: "Raw", exact: true }).click(); + await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(3); + await expect(home.getByRole("textbox", { name: "Search projects" })).toHaveCount(0); + await home.getByRole("textbox", { name: "Search raw files" }).fill("mic"); + await expect(home.getByRole("list", { name: "Raw files" }).locator("li")).toHaveCount(1); + await home.getByRole("button", { name: "Show screen.mic.wav in folder" }).click(); + await expect(page.locator("html")).toHaveAttribute( + "data-revealed", + "/recordings/screen.mic.wav", + ); + await page.screenshot({ path: "test-results/dashboard-raw.png" }); +});