Add project dashboard and Raw library with consistent Solar UI

This commit is contained in:
webadderall
2026-09-22 19:48:30 +10:00
parent 6cdd223001
commit 710a93d690
98 changed files with 3533 additions and 398 deletions
+5
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+18 -2
View File
@@ -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<void>;
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;
@@ -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([]);
});
@@ -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 });
}
}
+3 -4
View File
@@ -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,
@@ -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 });
}
});
@@ -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;
}
@@ -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 });
}
});
@@ -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<ReturnType<typeof fs.open>> | 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();
}
}
@@ -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",
]);
});
+45
View File
@@ -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<void>;
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 };
}
+8
View File
@@ -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"]);
});
+2 -2
View File
@@ -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<RecordingLibraryEntry[]> {
export function listRecordings(includeSources = false): Promise<RecordingLibraryEntry[]> {
const task = mutation.then(async () => {
const root = await fs.realpath(await getRecordingsDir());
const server = getMediaServerBaseUrl();
@@ -31,7 +31,7 @@ export function listRecordings(): Promise<RecordingLibraryEntry[]> {
}
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;
+56 -2
View File
@@ -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<number, AbortController>();
const pendingImports = new Map<number, Set<string>>();
const watchedImportSenders = new WeakSet<Electron.WebContents>();
@@ -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();
+18 -2
View File
@@ -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();
});
}
+1
View File
@@ -65,6 +65,7 @@ export type RecordingSessionManifest = {
export type ProjectLibraryEntry = {
path: string;
name: string;
createdAt?: number;
updatedAt: number;
thumbnailPath: string | null;
isCurrent: boolean;
+8 -2
View File
@@ -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: (
@@ -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();
});
+24
View File
@@ -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();
},
};
}
+14
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="240" viewBox="0 0 1440 240"><defs><linearGradient id="bg" x2="1" y2="1"><stop stop-color="#16213d"/><stop offset="1" stop-color="#384974"/></linearGradient></defs><path fill="url(#bg)" d="M0 0h1440v240H0z"/><g fill="none" stroke="#9baed8" stroke-opacity=".15"><circle cx="1160" cy="130" r="210"/><circle cx="1160" cy="130" r="150"/><circle cx="1160" cy="130" r="90"/></g><text x="160" y="106" fill="#c4cfe8" font-family="sans-serif" font-size="14" letter-spacing="3">ANNOUNCEMENTS</text><text x="160" y="150" fill="#fff" font-family="sans-serif" font-size="32">Space for whats next.</text></svg>

After

Width:  |  Height:  |  Size: 660 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="240" viewBox="0 0 1440 240"><defs><linearGradient id="bg" x2="1" y2="1"><stop stop-color="#29343b"/><stop offset="1" stop-color="#436566"/></linearGradient></defs><path fill="url(#bg)" d="M0 0h1440v240H0z"/><g fill="none" stroke="#b5d7d1" stroke-opacity=".16" transform="translate(1110 120) rotate(30)"><rect x="-150" y="-150" width="300" height="300" rx="40"/><rect x="-100" y="-100" width="200" height="200" rx="30"/></g><text x="160" y="106" fill="#c1dcd5" font-family="sans-serif" font-size="14" letter-spacing="3">COMING SOON</text><text x="160" y="150" fill="#fff" font-family="sans-serif" font-size="32">More to explore.</text></svg>

After

Width:  |  Height:  |  Size: 702 B

@@ -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";
@@ -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";
+1 -1
View File
@@ -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 {
+3 -4
View File
@@ -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() {
}
/>
<Separator orientation="vertical" className="mx-[5px] h-6" />
<Separator orientation="vertical" className="mx-[5px] h-6 self-center" />
</>
)}
@@ -364,7 +363,7 @@ function LaunchWindowContent() {
<div className={styles.recDot} />
</Button>
<Separator orientation="vertical" className="mx-[5px] h-6" />
<Separator orientation="vertical" className="mx-[5px] h-6 self-center" />
<div className="relative w-0 h-0">
<ProjectPopover
+78 -106
View File
@@ -5,149 +5,121 @@ import {
PauseIcon,
PlayIcon,
XIcon,
} from "@phosphor-icons/react";
import { useMemo } from "react";
} from "@/components/ui/icons";
import { useScopedT } from "@/contexts/I18nContext";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Button, Separator, Tooltip } from "@heroui/react";
import styles from "./LaunchWindow.module.css";
interface RecordingControlsProps {
paused: boolean;
microphoneEnabled: boolean;
elapsed: number;
onToggleMicrophone: () => 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 (
<>
<div className="flex items-center gap-[5px]">
<div
className={`w-[7px] h-[7px] rounded-full ${
paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}`
}`}
/>
<span
className={`text-[10px] font-bold tracking-[0.06em] ${
paused ? "text-[#fbbf24]" : "text-[#f43f5e]"
}`}
>
{paused ? t("recording.paused") : t("recording.rec")}
</span>
</div>
const actionClass = `size-9 min-w-9 rounded-full ${styles.electronNoDrag}`;
return (
<div role="group" aria-label="Recording controls" className="flex items-center gap-2">
<div
className="flex items-center gap-3 px-2"
role="status"
aria-label={paused ? t("recording.paused") : t("recording.rec")}
>
<span
className={`font-mono text-xs font-semibold min-w-[52px] text-center tracking-[0.02em] ${
paused ? "text-[#fbbf24]" : "text-[var(--launch-text)]"
}`}
>
className={`size-2 rounded-full ${paused ? "bg-warning" : `bg-danger ${styles.recDotBlink}`}`}
/>
<span className="min-w-14 text-sm font-medium tabular-nums text-foreground">
{formatTime(elapsed)}
</span>
<Separator orientation="vertical" className="mx-[5px] h-6" />
<span title={t("recording.micToggleDisabledTip")}>
<Button
variant="ghost"
size="icon"
iconSize="lg"
className={microphoneEnabled ? "text-accent" : ""}
aria-label={t("recording.micToggleDisabledTip")}
disabled
onClick={onToggleMicrophone}
>
{microphoneEnabled ? (
<MicrophoneIcon size={18} />
) : (
<MicrophoneSlashIcon size={18} />
)}
</Button>
</span>
<Separator orientation="vertical" className="mx-[5px] h-6" />
{paused && (
<span className="text-xs text-muted-foreground">{t("recording.paused")}</span>
)}
</div>
<Tooltip>
<Button
variant={paused ? "default" : "ghost"}
size="icon"
iconSize="lg"
onClick={onPauseResume}
title={paused ? t("recording.resume") : t("recording.pause")}
aria-label={paused ? t("recording.resume") : t("recording.pause")}
className={paused ? "text-success" : ""}
isIconOnly
variant="ghost"
isDisabled
className={actionClass}
aria-label={microphoneEnabled ? "Microphone on" : "Microphone off"}
>
{paused ? (
<PlayIcon size={18} fill="currentColor" strokeWidth={0} />
{microphoneEnabled ? (
<MicrophoneIcon weight="fill" className="size-4" />
) : (
<PauseIcon size={18} />
<MicrophoneSlashIcon className="size-4" />
)}
</Button>
<Button
type="button"
onClick={onStopRecording}
title={t("recording.stop")}
aria-label={t("recording.stop")}
variant="destructive"
size="icon"
className={styles.electronNoDrag}
>
<span className={styles.stopSquare} />
</Button>
<Tooltip.Content>{t("recording.micToggleDisabledTip")}</Tooltip.Content>
</Tooltip>
<Separator orientation="vertical" className="mx-1 h-5 self-center" />
<Tooltip>
<Button
isIconOnly
variant="ghost"
size="icon"
iconSize="lg"
onClick={onHideHud}
title={t("recording.hideHud")}
className={actionClass}
onPress={onPauseResume}
aria-label={paused ? t("recording.resume") : t("recording.pause")}
>
{paused ? (
<PlayIcon weight="fill" className="size-4" />
) : (
<PauseIcon weight="fill" className="size-4" />
)}
</Button>
<Tooltip.Content>
{paused ? t("recording.resume") : t("recording.pause")}
</Tooltip.Content>
</Tooltip>
<Tooltip>
<Button
isIconOnly
variant="danger"
className={actionClass}
onPress={onStopRecording}
aria-label={t("recording.stop")}
>
<span className="size-3 rounded-[3px] bg-current" />
</Button>
<Tooltip.Content>{t("recording.stop")}</Tooltip.Content>
</Tooltip>
<Tooltip>
<Button
isIconOnly
variant="ghost"
className={actionClass}
onPress={onHideHud}
aria-label={t("recording.hideHud")}
>
<MinusIcon size={16} />
<MinusIcon className="size-4" />
</Button>
<Tooltip.Content>{t("recording.hideHud")}</Tooltip.Content>
</Tooltip>
<Tooltip>
<Button
isIconOnly
variant="ghost"
size="icon"
iconSize="lg"
onClick={onCancelRecording}
title={t("recording.cancel")}
className={actionClass}
onPress={onCancelRecording}
aria-label={t("recording.cancel")}
>
<XIcon size={18} />
<XIcon className="size-4" />
</Button>
</>
);
}, [
paused,
microphoneEnabled,
elapsed,
onToggleMicrophone,
onPauseResume,
onStopRecording,
onHideHud,
onCancelRecording,
formatTime,
t,
]);
return memoizedControls;
};
<Tooltip.Content>{t("recording.cancel")}</Tooltip.Content>
</Tooltip>
</div>
);
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
+24
View File
@@ -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");
});
+52
View File
@@ -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<User | null>(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 (
<Avatar
aria-label={profile.name}
className={`shrink-0 !rounded-full bg-accent/15 text-accent ${className}`}
>
{profile.picture && (
<Avatar.Image
src={profile.picture}
alt={profile.name}
className="!rounded-full object-cover"
/>
)}
<Avatar.Fallback className="!rounded-full bg-accent/15 text-xs font-medium text-accent">
{profile.initials}
</Avatar.Fallback>
</Avatar>
);
}
+298
View File
@@ -0,0 +1,298 @@
import type { IconProps as SolarIconProps } from "@solar-icons/react/lib/types";
import type { ComponentType, SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement> & {
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<SolarIconProps>, Bold: ComponentType<SolarIconProps>, slash = false) =>
({ weight, mirrored, size, ...props }: IconProps) => {
const Icon = weight === "fill" ? Bold : Linear;
const style = { ...props.style, ...(mirrored ? { transform: "scaleX(-1)" } : {}) };
if (slash)
return (
<svg
{...props}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
style={style}
data-icon-style={weight === "fill" ? "bold" : "linear"}
>
<Icon size={24} />
<path
d="M4 4L20 20"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
return (
<Icon
{...props}
size={size}
data-icon-style={weight === "fill" ? "bold" : "linear"}
style={style}
/>
);
};
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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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 (
@@ -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";
@@ -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";
@@ -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";
@@ -6,6 +6,7 @@ import { toFileUrl } from "./projectPersistence";
export type ProjectLibraryEntry = {
path: string;
name: string;
createdAt?: number;
updatedAt: number;
thumbnailPath: string | null;
isCurrent: boolean;
@@ -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";
@@ -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";
+1 -1
View File
@@ -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 {
@@ -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";
@@ -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<string | undefined>;
@@ -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;
@@ -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<string, string>();
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();
});
@@ -0,0 +1,34 @@
const KEY = "recordly.project-share-links.v1";
function readLinks(): Record<string, string> {
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));
}
@@ -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 (
<>
<Modal isOpen={props.open} onOpenChange={props.onOpenChange}>
<Modal.Backdrop>
<Modal.Container size="full">
<Modal.Dialog
aria-label="Projects dashboard"
className="dashboard-surface dashboard-shell flex h-full flex-row gap-0 rounded-none bg-background p-0 text-foreground"
>
<DashboardSidebar {...view} />
<Card className="my-3 mr-3 flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-2xl bg-background p-0 shadow-sm">
{model.section !== "settings" &&
model.section !== "shared" &&
model.section !== "raw" && (
<>
<DashboardAnnouncements />
<DashboardToolbar {...view} />
<DashboardFilters {...view} />
</>
)}
<DashboardGrid {...view} />
</Card>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<DashboardDialogs {...view} />
</>
);
}
@@ -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(
<DashboardAnnouncements config={{ enabled: false, banners: [banner] }} />,
),
).toBe("");
expect(
renderToStaticMarkup(
<DashboardAnnouncements config={{ enabled: true, banners: [] }} />,
),
).toBe("");
});
it("renders a linked single image without navigation", () => {
const html = renderToStaticMarkup(
<DashboardAnnouncements config={{ enabled: true, banners: [banner] }} />,
);
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(
<DashboardAnnouncements
config={{
enabled: true,
banners: [{ ...banner, href: "javascript:alert(1)" }, banner],
}}
/>,
);
expect(html).toContain("Next announcement");
expect(html).not.toContain("javascript:");
});
});
@@ -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 = (
<img
src={
/^(https?:|data:|\/)/.test(banner.src)
? banner.src
: `${import.meta.env.BASE_URL}${banner.src}`
}
alt={banner.alt}
className="block aspect-[6/1] w-full object-cover"
/>
);
const href = banner.href && /^https?:\/\//.test(banner.href) ? banner.href : undefined;
return (
<Card
aria-label="Announcements"
aria-roledescription="carousel"
className="relative mx-7 mt-7 shrink-0 overflow-hidden rounded-2xl p-0 shadow-none lg:mx-10"
>
<div aria-live="polite" aria-atomic="true">
{href ? (
<Link
href={href}
className="block w-full"
onClick={(event) => {
event.preventDefault();
void window.electronAPI
.openExternalUrl(href)
.catch(() => toast.error("Could not open announcement"));
}}
>
{image}
</Link>
) : (
image
)}
</div>
{banners.length > 1 && (
<div className="absolute inset-x-3 bottom-3 flex items-center justify-between">
<Button
isIconOnly
size="sm"
variant="secondary"
aria-label="Previous announcement"
onPress={() => setIndex((active + banners.length - 1) % banners.length)}
>
<ArrowLeft className="size-4" />
</Button>
<div
className="flex gap-2 rounded-full bg-black/30 px-2 py-0.5"
aria-label="Choose announcement"
>
{banners.map((banner, slide) => (
<Button
key={banner.src}
isIconOnly
variant="ghost"
aria-label={`Announcement ${slide + 1}`}
aria-pressed={slide === active}
onPress={() => setIndex(slide)}
className="!size-6 !min-h-6 !min-w-6 rounded-full p-0"
>
<span
className={`size-1.5 rounded-full ${slide === active ? "bg-white" : "bg-white/40"}`}
/>
</Button>
))}
</div>
<Button
isIconOnly
size="sm"
variant="secondary"
aria-label="Next announcement"
onPress={() => setIndex((active + 1) % banners.length)}
>
<ArrowRight className="size-4" />
</Button>
</div>
)}
</Card>
);
}
@@ -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 (
<>
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
Delete {selected.length} project{selected.length === 1 ? "" : "s"}?
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Project files move to Trash. Source recordings are kept.
</p>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={busy}
onClick={() =>
void run(async () => {
const deleted = await onDeleteProjects(selected);
removeProjectShareLinks(deleted);
save(
folders.map((f) => ({
...f,
paths: f.paths.filter((p) => !deleted.includes(p)),
})),
);
setSelected(selected.filter((p) => !deleted.includes(p)));
setConfirmDelete(false);
if (deleted.length === selected.length) setSelecting(false);
})
}
>
Move to Trash
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -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 (
<>
<div className="flex items-center justify-between gap-3 px-7 pb-7 lg:px-10">
<div className="flex gap-2" aria-label="Time filters">
{[
["all", "All"],
["week", "Last 7 days"],
["month", "Last 30 days"],
].map(([id, label]) => (
<Button
key={id}
variant="ghost"
size="sm"
aria-pressed={period === id}
onClick={() => setPeriod(id)}
className={`h-7 rounded-lg px-3 text-xs ${period === id ? "bg-default/60 text-foreground" : "text-muted-foreground"}`}
>
{label}
</Button>
))}
<Button
variant="ghost"
size="icon"
className="size-7 min-w-7 text-danger"
aria-label="Select projects to delete"
aria-pressed={selecting}
onClick={() => {
setSelecting(!selecting);
setSelected([]);
}}
>
<Trash weight="fill" className="size-4" />
</Button>
</div>
<Dropdown>
<Button
variant="ghost"
size="sm"
aria-label="Sort projects"
className="h-7 gap-2 text-xs text-muted-foreground"
>
{sort === "recent"
? "Last edited"
: sort === "created"
? "Last created"
: "Name"}
<CaretDown className="size-3" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Sort projects">
<Dropdown.Item id="recent" onAction={() => setSort("recent")}>
Last edited
</Dropdown.Item>
<Dropdown.Item id="created" onAction={() => setSort("created")}>
Last created
</Dropdown.Item>
<Dropdown.Item id="name" onAction={() => setSort("name")}>
Name
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
{selecting && (
<div className="flex items-center gap-3 px-7 pb-5 text-xs lg:px-10">
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(visible.map((e) => e.path))}
>
Select all
</Button>
<span>{selected.length} selected</span>
<Button
variant="destructive"
size="sm"
disabled={!selected.length || busy}
onClick={() => setConfirmDelete(true)}
>
Delete
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelecting(false);
setSelected([]);
}}
>
Cancel
</Button>
</div>
)}
</>
);
}
@@ -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 (
<>
<main className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-7 pb-10 lg:px-10">
{error && (
<p role="alert" className="mb-4 text-sm text-danger">
{error}
</p>
)}
{section === "settings" ? (
<DashboardSettings />
) : section === "raw" ? (
<RawRecordings />
) : section === "shared" ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<Cloud weight="fill" className="size-8 opacity-40" />
<p>
{accountLabel
? "Shared videos are managed in your cloud library."
: "Sign in to manage shared videos."}
</p>
<Button variant="secondary" onClick={onSignIn}>
{accountLabel ? "Account" : "Sign in"}
</Button>
</div>
) : visible.length ? (
<ul
aria-label="Your projects"
className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-x-7 gap-y-10 lg:gap-x-9"
>
{visible.map((entry) => (
<ProjectCard
key={entry.path}
{...{
accountLabel,
entry,
busy,
selecting,
selected,
toggleSelected,
openEntry,
run,
onShareProject,
onRenameProject,
folders,
save,
assignFolder,
}}
/>
))}
</ul>
) : (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<ImageSquare weight="fill" className="size-8 opacity-30" />
<p>{query ? "No matching projects" : "No projects yet"}</p>
{query && (
<Button variant="ghost" size="sm" onClick={() => setQuery("")}>
Clear search
</Button>
)}
</div>
)}
</main>
</>
);
}
@@ -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<ReactNode>(null);
export function DashboardSettings() {
const settingsContent = useContext(DashboardSettingsContext);
const [directory, setDirectory] = useState("");
return (
<section aria-label="Dashboard settings" className="dashboard-settings max-w-2xl py-10">
<h1 className="mb-8 text-lg font-semibold">Settings</h1>
<div className="space-y-8">
{settingsContent}
<div className="flex items-center justify-between gap-8">
<div>
<p className="text-sm">Projects folder</p>
{directory && (
<p
className="mt-1 max-w-xs truncate text-xs text-muted-foreground"
title={directory}
>
{directory}
</p>
)}
</div>
<Button
variant="secondary"
onClick={async () => {
try {
const result = await window.electronAPI.getProjectsDirectory();
if (!result.success || !result.path)
throw Error("Could not open projects folder");
setDirectory(result.path);
await window.electronAPI.revealInFolder(result.path);
} catch (e) {
toast.error(String(e));
}
}}
>
Show folder
</Button>
</div>
<p className="text-xs text-muted-foreground">
Named projects save automatically. Previews refresh when you return to Projects.
</p>
</div>
</section>
);
}
@@ -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 (
<>
<aside
aria-label="Library navigation"
className="flex w-48 shrink-0 flex-col bg-transparent px-4 pb-5 pt-12 lg:w-56"
>
<div className="mb-9 flex h-10 items-center gap-2.5 px-3">
<img
src={`${import.meta.env.BASE_URL}app-icons/recordly-64.png`}
alt=""
className="size-7"
/>
<span className="text-[15px] font-semibold tracking-tight">Recordly</span>
</div>
<nav className="space-y-1">
<Button
variant="ghost"
className={navClass(section === "projects")}
aria-current={section === "projects" ? "page" : undefined}
onClick={() => setSection("projects")}
>
<ImageSquare
weight={section === "projects" ? "fill" : "regular"}
className="size-[18px]"
/>
Projects
</Button>
<Button
variant="ghost"
className={navClass(section === "shared")}
aria-current={section === "shared" ? "page" : undefined}
onClick={() => setSection("shared")}
>
<Cloud
weight={section === "shared" ? "fill" : "regular"}
className="size-[18px]"
/>
Shared
</Button>
<Button
variant="ghost"
className={navClass(section === "raw")}
aria-current={section === "raw" ? "page" : undefined}
onClick={() => setSection("raw")}
>
<File
weight={section === "raw" ? "fill" : "regular"}
className="size-[18px]"
/>
Raw
</Button>
</nav>
<div className="mb-2 mt-9 flex items-center justify-between pl-3">
<h2 className="text-[13px] font-semibold tracking-tight text-foreground/80">
Folders
</h2>
<Button
variant="ghost"
size="icon"
className="size-7 min-w-7"
aria-label="New folder"
onClick={() => {
let name = "Untitled folder",
index = 1;
while (folders.some((folder) => folder.name === name))
name = `Untitled folder ${index++}`;
save([
...folders,
{
id: crypto.randomUUID(),
name,
color: FOLDER_COLORS[0],
paths: [],
},
]);
}}
>
<Plus className="size-3.5" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto space-y-1">
{folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={section === folder.id}
onSelect={() => setSection(folder.id)}
onChange={(next) =>
save(folders.map((item) => (item.id === next.id ? next : item)))
}
onRemove={() => {
save(folders.filter((item) => item.id !== folder.id));
if (section === folder.id) setSection("projects");
}}
colors={metadata.colors}
onColors={(colors) => update({ ...metadata, colors })}
/>
))}
</div>
<div className="space-y-1 pt-6">
<Button
variant="ghost"
className={navClass(false)}
onClick={() => void run(onImportFile)}
>
<UploadSimple className="size-[18px]" />
Import
</Button>
<Button
variant="ghost"
className={navClass(false)}
onClick={() => onOpenChange(false)}
>
<ArrowLeft className="size-[18px]" />
Back to editor
</Button>
<Button
variant="ghost"
className={navClass(section === "settings")}
aria-current={section === "settings" ? "page" : undefined}
onClick={() => setSection("settings")}
>
<GearSix
weight={section === "settings" ? "fill" : "regular"}
className="size-[18px]"
/>
Settings
</Button>
<Button variant="ghost" className={navClass(false)} onClick={onSignIn}>
<UserCircle weight="fill" className="size-[18px] shrink-0" />
<span className="truncate">{accountLabel || "Sign in"}</span>
</Button>
</div>
</aside>
</>
);
}
@@ -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<DashboardProps & DashboardModel, "query" | "setQuery" | "run" | "busy">) {
return (
<>
<header
className="flex h-24 shrink-0 items-center gap-5 px-7 pt-5 lg:px-10"
style={{ WebkitAppRegion: "drag" } as CSSProperties}
>
<div
className="relative min-w-0 flex-1"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<MagnifyingGlass className="pointer-events-none absolute left-3.5 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={query}
onChange={(e) => 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"
/>
</div>
<Button
variant="default"
onClick={() => void run(() => window.electronAPI.showRecordingHud())}
disabled={busy}
className="h-10 gap-2 text-[13px]"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<Plus className="size-4" />
New
</Button>
</header>
</>
);
}
@@ -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 (
<ColorPicker value={value} onChange={(color) => onChange(color.toString("hex"))}>
<Button
variant="ghost"
size="icon"
aria-label={`Change color for ${name}`}
className="size-8 min-w-8 rounded-md"
>
<FolderSimple weight="fill" className="size-[18px]" style={{ color: value }} />
</Button>
<ColorPicker.Popover>
<Popover.Dialog aria-label="Folder color" className="flex w-60 flex-col gap-3 p-3">
<ColorSwatchPicker aria-label="Folder colors">
{colors.map((color) => (
<ColorSwatchPicker.Item key={color} color={color}>
<ColorSwatchPicker.Swatch />
<ColorSwatchPicker.Indicator />
</ColorSwatchPicker.Item>
))}
</ColorSwatchPicker>
<ColorArea
colorSpace="hsb"
xChannel="saturation"
yChannel="brightness"
className="h-32"
>
<ColorArea.Thumb />
</ColorArea>
<ColorSlider colorSpace="hsb" channel="hue">
<ColorSlider.Track>
<ColorSlider.Thumb />
</ColorSlider.Track>
</ColorSlider>
<div className="flex items-center gap-2">
<ColorField aria-label="Hex color" className="min-w-0 flex-1">
<Input aria-label="Hex color" className="h-8 text-xs" />
</ColorField>
<Button
variant="ghost"
size="icon"
aria-label={
custom.includes(value) ? "Remove custom color" : "Save custom color"
}
disabled={FOLDER_COLORS.includes(value)}
className="size-8 min-w-8"
onClick={() =>
onCustomChange(
custom.includes(value)
? custom.filter((color) => color !== value)
: [...custom, value],
)
}
>
{custom.includes(value) ? (
<X className="size-4" />
) : (
<Plus className="size-4" />
)}
</Button>
</div>
</Popover.Dialog>
</ColorPicker.Popover>
</ColorPicker>
);
}
@@ -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 (
<div
className={`folder-row group flex h-10 items-center gap-1 rounded-lg px-1 ${active ? "bg-default/70" : "hover:bg-default/40"}`}
>
<FolderColors
value={folder.color}
name={folder.name}
onChange={(color) => onChange({ ...folder, color })}
custom={colors}
onCustomChange={onColors}
/>
{editing ? (
<form
className="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault();
finish();
}}
>
<Input
autoFocus
aria-label="Folder name"
className="h-8 w-full min-w-0 border-0 bg-transparent px-0 text-[13px] shadow-none"
value={draft}
maxLength={80}
onChange={(event) => setDraft(event.target.value)}
onBlur={finish}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
/>
</form>
) : (
<Button
variant="ghost"
aria-current={active ? "page" : undefined}
onClick={onSelect}
onDoubleClick={edit}
className="h-full min-w-0 flex-1 justify-start rounded-none px-0 text-[13px]"
>
<span className="truncate">{folder.name}</span>
</Button>
)}
<Dropdown>
<Button
variant="ghost"
size="icon"
aria-label={`Options for ${folder.name}`}
className="size-6 min-w-6 text-muted-foreground opacity-0 group-hover:opacity-100 focus:opacity-100"
>
<DotsThree className="size-4" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Folder options">
<Dropdown.Item id="rename" onAction={edit}>
Rename
</Dropdown.Item>
<Dropdown.Item id="remove" onAction={onRemove}>
Remove folder
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
);
}
@@ -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 (
<li className="group min-w-0">
<Button
variant="ghost"
disabled={busy}
aria-label={entry.name}
onClick={() => (selecting ? toggleSelected(entry.path) : openEntry(entry))}
aria-pressed={selecting ? selected.includes(entry.path) : undefined}
className="relative block h-auto w-full min-w-0 rounded-xl p-0"
>
<ProjectThumbnail
key={`${entry.thumbnailPath}-${entry.updatedAt}`}
revision={entry.updatedAt}
path={entry.thumbnailPath}
/>
{selecting && (
<span
className={`absolute right-2 top-2 flex size-5 items-center justify-center rounded-md ${selected.includes(entry.path) ? "bg-accent text-white" : "bg-background/90"}`}
>
{selected.includes(entry.path) && <Check className="size-3.5" />}
</span>
)}
</Button>
<div className="flex items-start justify-between gap-3 pt-4">
<AccountAvatar label={accountLabel} className="!size-[46px]" />
<div data-project-caption className="min-w-0 flex-1">
{editing ? (
<form
onSubmit={(event) => {
event.preventDefault();
rename();
}}
>
<input
autoFocus
aria-label="Project name"
className="inline-project-name h-6 w-full text-[12px] font-medium"
value={name}
disabled={busy}
maxLength={120}
onChange={(event) => setName(event.target.value)}
onBlur={rename}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
/>
</form>
) : (
<p
title={entry.name}
className="truncate text-[12px] font-medium leading-[18px]"
>
{entry.name}
</p>
)}
<div className="mt-1 flex h-6 min-w-0 items-center gap-3 overflow-x-auto [scrollbar-width:none]">
<p className="shrink-0 text-[11px] text-muted-foreground">
{new Date(entry.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</p>
<div
aria-label={`Folders for ${entry.name}`}
className="flex min-w-0 items-center gap-2"
>
{assignedFolders.map((item) => (
<Button
key={item.id}
variant="ghost"
size="sm"
aria-label={`Remove ${entry.name} from ${item.name}`}
onClick={() => assignFolder(entry.path, item.id)}
className="h-6 min-w-0 max-w-28 shrink-0 gap-1.5 rounded-full bg-default/40 px-2.5 text-[11px]"
>
<FolderSimple
weight="fill"
className="size-3 shrink-0"
style={{ color: item.color }}
/>
<span className="truncate">{item.name}</span>
</Button>
))}
<Dropdown>
<Button
variant="ghost"
size="sm"
aria-label={`Add folder to ${entry.name}`}
className="h-6 min-w-0 shrink-0 gap-1.5 rounded-full px-2.5 text-[11px] text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
>
<Plus className="size-3" />
Add folder
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Assign project folder">
{folders.map((item) => (
<Dropdown.Item
key={item.id}
id={item.id}
textValue={item.name}
onAction={() => assignFolder(entry.path, item.id)}
>
<FolderSimple
weight="fill"
style={{ color: item.color }}
/>
{item.name}
{item.paths.includes(entry.path) && (
<Check className="size-3" />
)}
</Dropdown.Item>
))}
{folder && (
<Dropdown.Item
id="remove"
onAction={() => assignFolder(entry.path, "none")}
>
Remove from all folders
</Dropdown.Item>
)}
{!folders.length && (
<Dropdown.Item id="empty" isDisabled>
Create a folder in the sidebar
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</div>
</div>
<Dropdown>
<Button
variant="ghost"
size="icon"
aria-label={`Options for ${entry.name}`}
className="size-6 min-w-6 text-muted-foreground"
>
<DotsThree weight="bold" className="size-5" />
</Button>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Project options">
<Dropdown.Item id="open" onAction={() => openEntry(entry)}>
Open project
</Dropdown.Item>
<Dropdown.Item
id="rename"
onAction={() => {
setName(entry.name);
setEditing(true);
}}
>
Rename
</Dropdown.Item>
<Dropdown.Item
id="share"
onAction={() =>
void run(async () => {
if (shareUrl)
await window.electronAPI.openExternalUrl(shareUrl);
else await onShareProject(entry.path);
})
}
>
{shareUrl ? "View in web" : "Share"}
</Dropdown.Item>
<Dropdown.Item
id="reveal"
onAction={() =>
void run(async () => {
await window.electronAPI.revealInFolder(entry.path);
})
}
>
Show in folder
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</li>
);
}
@@ -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<string | null>(null);
const sourceKey = `${path}:${revision}`;
return (
<div className="flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-xl bg-default/60">
{path && failedSource !== sourceKey ? (
<img
src={
/^(data:|blob:)/.test(path)
? path
: `${/^https?:/.test(path) ? path : toFileUrl(path)}?v=${revision}`
}
alt=""
loading="lazy"
draggable={false}
onError={() => setFailedSource(sourceKey)}
className="h-full w-full object-contain"
/>
) : (
<ImageSquare weight="fill" className="size-8 text-muted-foreground/20" />
)}
</div>
);
}
@@ -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<RecordingLibraryEntry[]>([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [preview, setPreview] = useState<RecordingLibraryEntry | null>(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 (
<section aria-label="Raw recordings" className="py-10">
<header className="mb-8 flex items-center justify-between gap-5">
<div>
<h1 className="text-lg font-semibold">Raw</h1>
<p className="mt-2 text-sm text-muted-foreground">
Original screen recordings, camera footage, and audio files.
</p>
</div>
<Button variant="secondary" onPress={() => void reveal()}>
<FolderOpen />
Show folder
</Button>
</header>
<Input
aria-label="Search raw files"
placeholder="Search raw files…"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="mb-6 w-full"
/>
{loading ? (
<p role="status" className="py-12 text-sm text-muted-foreground">
Loading recordings
</p>
) : error ? (
<div role="alert">
<p>{error}</p>
<Button variant="secondary" onPress={() => void refresh()}>
Retry
</Button>
</div>
) : visible.length ? (
<ul aria-label="Raw files" className="space-y-3">
{visible.map((entry) => (
<li
key={entry.path}
className="flex items-center gap-4 rounded-xl bg-default/20 p-4"
>
<File className="size-6 shrink-0 text-muted-foreground" />
<Button
variant="ghost"
className="h-auto min-w-0 flex-1 justify-start p-0 text-left"
onPress={() => setPreview(entry)}
>
<span className="min-w-0">
<span className="block truncate text-sm">{entry.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString()} ·{" "}
{(entry.bytes / 1048576).toFixed(1)} MB
</span>
</span>
</Button>
<Button
isIconOnly
variant="ghost"
aria-label={`Show ${entry.name} in folder`}
onPress={() => void reveal(entry.path)}
>
<FolderOpen className="size-4" />
</Button>
</li>
))}
</ul>
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{query ? "No matching raw files" : "No raw recordings yet"}
</p>
)}
<Modal
isOpen={!!preview}
onOpenChange={(open) => {
if (!open) setPreview(null);
}}
>
<Modal.Backdrop>
<Modal.Container size="lg">
<Modal.Dialog aria-label="Raw file preview">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{preview?.name}</Modal.Heading>
</Modal.Header>
<Modal.Body>
{preview &&
(/\.(wav|m4a|mp3|ogg|flac)$/i.test(preview.name) ? (
<audio controls src={preview.url} className="w-full" />
) : (
<video
controls
src={preview.url}
className="max-h-[65vh] w-full rounded-xl"
/>
))}
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</section>
);
}
@@ -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.
],
};
@@ -0,0 +1,14 @@
import type { ProjectLibraryEntry } from "../ProjectBrowserDialog";
export type DashboardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
entries: ProjectLibraryEntry[];
onOpenProject: (path: string) => Promise<unknown>;
onImportFile: () => Promise<void>;
error: string | null;
onSignIn: () => void;
onDeleteProjects: (paths: string[]) => Promise<string[]>;
onRenameProject: (path: string, name: string) => Promise<string>;
onShareProject: (path: string) => Promise<void>;
accountLabel?: string;
};
@@ -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 };
}
@@ -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<string[]>([]);
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<void>) => {
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<typeof useDashboardModel>;
@@ -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<ProjectFolder[]>(() => {
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<ProjectFolder[]>).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 };
}
@@ -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<SetStateAction<boolean>>;
projectLibraryEntries: ProjectLibraryEntry[];
projectBrowserAnchorRef: RefObject<HTMLButtonElement | null>;
projectError: string | null;
onDashboardSignIn: () => void;
onDeleteProjects: (paths: string[]) => Promise<string[]>;
onRenameProject: (path: string, name: string) => Promise<string>;
onShareProject: (path: string) => Promise<void>;
accountLabel?: string;
handleImportMediaOrProject: () => Promise<void>;
handleOpenProjectFromLibrary: (projectPath: string) => Promise<void>;
handleOpenProjectFromLibrary: (projectPath: string) => Promise<unknown>;
nativeCaptureUnavailableModalOpen: boolean;
setNativeCaptureUnavailableModalOpen: Dispatch<SetStateAction<boolean>>;
}
@@ -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({
</DialogContent>
</Dialog>
<ProjectBrowserDialog
<Dashboard
open={projectBrowserOpen}
onOpenChange={setProjectBrowserOpen}
entries={projectLibraryEntries}
anchorRef={projectBrowserAnchorRef}
onImportFile={() => void handleImportMediaOrProject()}
onOpenProject={(projectPath) => void handleOpenProjectFromLibrary(projectPath)}
error={projectError}
onSignIn={onDashboardSignIn}
onDeleteProjects={onDeleteProjects}
onRenameProject={onRenameProject}
onShareProject={onShareProject}
accountLabel={accountLabel}
onImportFile={handleImportMediaOrProject}
onOpenProject={handleOpenProjectFromLibrary}
/>
<Dialog
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react";
import { CloudArrowUp } from "@phosphor-icons/react";
import { CloudArrowUp } from "@/components/ui/icons";
import { CloudShareButton } from "../cloud/CloudShareButton";
import { Card } from "@heroui/react";
import { ProgressBar } from "@heroui/react";
import { DownloadSimple as Download } from "@phosphor-icons/react";
import { DownloadSimple as Download } from "@/components/ui/icons";
import { toast } from "@/components/ui/toast";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -15,6 +15,7 @@ import type { useExportSettings } from "../export/useExportSettings";
import type { useExportStatusViewModel } from "../export/useExportStatusViewModel";
type Props = {
projectPath?: string | null;
t: ReturnType<typeof useI18n>["t"];
exportSettings: ReturnType<typeof useExportSettings>;
exportSession: ReturnType<typeof useExportSession>;
@@ -359,6 +360,7 @@ export function EditorExportMenu(props: Props) {
</Popover>
{shareOpen && (
<CloudShareButton
projectPath={props.projectPath}
hideTrigger
open={shareOpen}
onOpenChange={setShareOpen}
@@ -1,10 +1,8 @@
import { Input } from "@/components/ui/input";
import {
FolderOpen,
FilmStrip,
House,
ArrowClockwise as Redo2,
ArrowCounterClockwise as Undo2,
} from "@phosphor-icons/react";
} from "@/components/ui/icons";
import type { CSSProperties, FormEvent, RefObject } from "react";
import { Button } from "@/components/ui/button";
import type { useI18n } from "@/contexts/I18nContext";
@@ -14,13 +12,11 @@ import type { useExportSettings } from "../export/useExportSettings";
import type { useExportStatusViewModel } from "../export/useExportStatusViewModel";
import type { useVideoEditorPresets } from "../presets/useVideoEditorPresets";
import type { useProjectState } from "../state/useProjectState";
import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, FeedbackDialog } from "../TutorialHelp";
import { APP_HEADER_ICON_BUTTON_CLASS } from "../TutorialHelp";
import { EditorExportMenu } from "./EditorExportMenu";
import { EditorPresetMenu } from "./EditorPresetMenu";
type Props = {
videosOpen?: boolean;
onToggleVideos?: () => void;
t: ReturnType<typeof useI18n>["t"];
headerLeftControlsPaddingClass: string;
project: ReturnType<typeof useProjectState>;
@@ -100,25 +96,13 @@ export function EditorHeader(props: Props) {
return (
<header
className="editor-header [--text-sm:0.8125rem] relative z-50 grid h-14 shrink-0 border-b border-separator grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)_minmax(0,1fr)] items-center gap-3 px-4"
className="editor-header [--text-sm:0.8125rem] relative z-50 grid h-14 shrink-0 border-b border-separator grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-4"
style={{ WebkitAppRegion: "drag" } as CSSProperties}
>
<div
className={`editor-header-start flex items-center justify-self-start gap-1 ${headerLeftControlsPaddingClass}`}
className={`editor-header-start flex min-w-0 items-center gap-1 ${headerLeftControlsPaddingClass}`}
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<Button
type="button"
variant="secondary"
className="[--button-bg:var(--surface)] [--button-fg:var(--foreground)] mr-2 inline-flex h-9 min-w-[104px] items-center justify-center gap-2 px-4.5"
aria-expanded={props.videosOpen}
onClick={props.onToggleVideos}
>
<FilmStrip className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
{t("editor.library.videos", "Videos")}
</span>
</Button>
<Button
ref={projectBrowserTriggerRef}
type="button"
@@ -129,97 +113,101 @@ export function EditorHeader(props: Props) {
title={t("editor.project.projects", "Open projects")}
aria-label={t("editor.project.projects", "Open projects")}
>
<FolderOpen className="h-4 w-4" />
<House weight="fill" className="h-4 w-4" />
</Button>
<div className="editor-header-community flex items-center gap-1">
<DiscordLinkButton />
<FeedbackDialog />
</div>
<div className="mx-2 h-4 w-px shrink-0 bg-separator" />
<Button
type="button"
variant="ghost"
onClick={handleUndo}
disabled={!canUndo}
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
title={t("common.actions.undo", "Undo")}
aria-label={t("common.actions.undo", "Undo")}
<span
aria-hidden="true"
className="mx-2 shrink-0 text-xl font-light text-muted-foreground/60"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
onClick={handleRedo}
disabled={!canRedo}
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
title={t("common.actions.redo", "Redo")}
aria-label={t("common.actions.redo", "Redo")}
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
/
</span>
<div
className="editor-header-title flex min-w-0 items-center justify-center"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
{isEditingProjectName ? (
<form
onSubmit={(event) => void handleProjectNameSubmit(event)}
className="flex w-full min-w-0 items-center gap-1"
>
{hasUnsavedChanges ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<Input
ref={projectNameInputRef}
type="text"
value={projectNameDraft}
onChange={(event) => 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")}
/>
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</form>
) : (
<Button
variant="ghost"
type="button"
onClick={() => setIsEditingProjectName(true)}
className="inline-flex h-9 min-w-0 max-w-full items-center gap-1.5 px-3"
title={t("editor.project.renameTitle", "Rename project")}
aria-label={t("editor.project.renameTitle", "Rename project")}
>
{hasUnsavedChanges ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<span className="truncate text-[13px] font-medium tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="project-file-extension shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</Button>
)}
<div
className="editor-header-title flex min-w-0 flex-1 items-center"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
{isEditingProjectName ? (
<form
onSubmit={(event) => void handleProjectNameSubmit(event)}
className="flex w-full min-w-0 items-center gap-1.5 px-1"
>
{hasUnsavedChanges ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<input
ref={projectNameInputRef}
type="text"
value={projectNameDraft}
onChange={(event) => 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")}
/>
</form>
) : (
<Button
variant="ghost"
type="button"
onClick={() => setIsEditingProjectName(true)}
className="inline-flex h-9 min-w-0 max-w-full items-center gap-1.5 px-1"
title={t("editor.project.renameTitle", "Rename project")}
aria-label={t("editor.project.renameTitle", "Rename project")}
>
{hasUnsavedChanges ? (
<span className="size-1.5 shrink-0 rounded-full bg-accent" />
) : null}
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
</Button>
)}
</div>
</div>
<div
className="editor-header-end flex min-w-0 items-center justify-self-end gap-3"
style={{ WebkitAppRegion: "no-drag" } as CSSProperties}
>
<div className="flex items-center gap-1">
<div className="mx-2 h-4 w-px shrink-0 bg-separator" />
<Button
type="button"
variant="ghost"
onClick={handleUndo}
disabled={!canUndo}
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
title={t("common.actions.undo", "Undo")}
aria-label={t("common.actions.undo", "Undo")}
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
onClick={handleRedo}
disabled={!canRedo}
className="inline-flex h-9 w-9 min-w-9 items-center justify-center p-0 disabled:cursor-not-allowed"
title={t("common.actions.redo", "Redo")}
aria-label={t("common.actions.redo", "Redo")}
>
<Redo2 className="h-4 w-4" />
</Button>
</div>
<EditorPresetMenu t={t} presets={presets} />
<EditorExportMenu
t={t}
@@ -238,6 +226,7 @@ export function EditorHeader(props: Props) {
handleStartExportFromDropdown={handleStartExportFromDropdown}
revealExportedFile={revealExportedFile}
exportMessage={exportMessage}
projectPath={project.currentProjectPath}
projectTitle={projectDisplayName}
prepareExportForShare={props.prepareExportForShare}
onRequestShareSignIn={props.onRequestShareSignIn}
@@ -1,4 +1,4 @@
import { BookmarkSimple, CaretDown, Check, X } from "@phosphor-icons/react";
import { BookmarkSimple, CaretDown, Check, X } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -15,7 +15,7 @@ import {
SpeakerHigh,
SpeakerLow,
SpeakerX,
} from "@phosphor-icons/react";
} from "@/components/ui/icons";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -1,3 +1,5 @@
import { AccountProfileContext } from "@/components/ui/account-avatar";
import { DashboardSettingsContext } from "../dashboard/DashboardSettings";
import { RecordlySignInDialog, type SignInReason } from "@/components/auth/RecordlySignInDialog";
import { useRecordlyAuth } from "@/components/auth/useRecordlyAuth";
import { useVideoSourceRecovery } from "../hooks/useVideoSourceRecovery";
@@ -132,31 +134,57 @@ export function EditorShell(props: Props) {
} = editing;
const { dialogActions, status: exportStatus, exportMessage } = exportController;
const editorDialogs = (
<EditorDialogs
t={t}
projectSaveDialogOpen={project.projectSaveDialogOpen}
setProjectSaveDialogOpen={project.setProjectSaveDialogOpen}
projectSaveDialogDraft={project.projectSaveDialogDraft}
setProjectSaveDialogDraft={project.setProjectSaveDialogDraft}
projectSaveDialogInputRef={ui.projectSaveDialogInputRef}
isSavingProjectDialog={project.isSavingProjectDialog}
resolveProjectSaveDialog={lifecycle.resolveProjectSaveDialog}
handleProjectSaveDialogSubmit={saveActions.handleProjectSaveDialogSubmit}
unsavedChangesDialogOpen={project.unsavedChangesDialogOpen}
setUnsavedChangesDialogOpen={project.setUnsavedChangesDialogOpen}
unsavedChangesDialogActionLabel={project.unsavedChangesDialogActionLabel}
resolveUnsavedChangesDialog={lifecycle.resolveUnsavedChangesDialog}
projectBrowserOpen={project.projectBrowserOpen}
setProjectBrowserOpen={project.setProjectBrowserOpen}
projectLibraryEntries={project.projectLibraryEntries}
projectBrowserAnchorRef={
project.error ? ui.projectBrowserFallbackTriggerRef : ui.projectBrowserTriggerRef
}
handleImportMediaOrProject={openActions.handleImportMediaOrProject}
handleOpenProjectFromLibrary={openActions.handleOpenProjectFromLibrary}
nativeCaptureUnavailableModalOpen={ui.nativeCaptureUnavailableModalOpen}
setNativeCaptureUnavailableModalOpen={ui.setNativeCaptureUnavailableModalOpen}
/>
<AccountProfileContext.Provider value={auth.user}>
<DashboardSettingsContext.Provider
value={
<SettingsPanel
{...settingsPanelProps}
activeEffectSection="settings"
selectedAnnotationId={null}
selectedClipId={null}
advanced
/>
}
>
<EditorDialogs
t={t}
projectSaveDialogOpen={project.projectSaveDialogOpen}
setProjectSaveDialogOpen={project.setProjectSaveDialogOpen}
projectSaveDialogDraft={project.projectSaveDialogDraft}
setProjectSaveDialogDraft={project.setProjectSaveDialogDraft}
projectSaveDialogInputRef={ui.projectSaveDialogInputRef}
isSavingProjectDialog={project.isSavingProjectDialog}
resolveProjectSaveDialog={lifecycle.resolveProjectSaveDialog}
handleProjectSaveDialogSubmit={saveActions.handleProjectSaveDialogSubmit}
unsavedChangesDialogOpen={project.unsavedChangesDialogOpen}
setUnsavedChangesDialogOpen={project.setUnsavedChangesDialogOpen}
unsavedChangesDialogActionLabel={project.unsavedChangesDialogActionLabel}
resolveUnsavedChangesDialog={lifecycle.resolveUnsavedChangesDialog}
projectBrowserOpen={project.projectBrowserOpen}
setProjectBrowserOpen={project.setProjectBrowserOpen}
projectLibraryEntries={project.projectLibraryEntries}
projectError={project.error}
onDashboardSignIn={() => 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}
/>
</DashboardSettingsContext.Provider>
</AccountProfileContext.Provider>
);
if (project.loading && !project.error)
return (
@@ -209,8 +237,6 @@ export function EditorShell(props: Props) {
return (
<div className="flex h-screen flex-col overflow-hidden bg-editor-bg text-foreground selection:bg-accent/20">
<EditorHeader
videosOpen={library.open}
onToggleVideos={() => library.setOpen((open) => !open)}
t={t}
headerLeftControlsPaddingClass={headerLeftControlsPaddingClass}
project={project}
@@ -295,6 +321,8 @@ export function EditorShell(props: Props) {
>
<div className="relative z-10 flex min-h-0 flex-1 pt-3">
<EditorSidebar
accountUser={auth.user}
onToggleVideos={() => library.setOpen((open) => !open)}
onAccountClick={() => requestSignIn("account")}
panelContent={
library.open ? <RecordingLibraryPanel library={library} /> : undefined
@@ -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<typeof useI18n>["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);
}}
>
<Tooltip>
<ToggleButton id="videos" variant="ghost" isIconOnly aria-label="Videos">
<File weight={panelContent ? "fill" : "regular"} className="size-5" />
</ToggleButton>
<Tooltip.Content placement="right">Videos</Tooltip.Content>
</Tooltip>
{sections.map((section) => (
<Tooltip key={section.id}>
<ToggleButton
@@ -94,7 +101,14 @@ export function EditorSidebar({
isIconOnly
aria-label={section.label}
>
<section.icon className="size-5" />
<section.icon
weight={
!panelContent && activeSection === section.id
? "fill"
: "regular"
}
className="size-5"
/>
</ToggleButton>
<Tooltip.Content placement="right">{section.label}</Tooltip.Content>
</Tooltip>
@@ -108,7 +122,7 @@ export function EditorSidebar({
aria-label="Recordly account"
onPress={onAccountClick}
>
<UserCircle className="size-5" />
<AccountAvatar user={accountUser} className="!size-7" />
</Button>
<Tooltip.Content placement="right">Account</Tooltip.Content>
</Tooltip>
@@ -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";
@@ -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 }) {
@@ -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;
@@ -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<number>;
applyLoadedProject: (candidate: unknown, path?: string | null) => Promise<boolean>;
openUnsavedChangesDialog: (actionLabel: string) => Promise<"save" | "discard" | "cancel">;
saveProject: (forceSaveAs: boolean) => Promise<boolean>;
saveProject: ReturnType<typeof useProjectSaveActions>["saveProject"];
refreshProjectLibrary: () => Promise<void>;
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,
};
}
@@ -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<number | null>(null);
const saveQueueRef = useRef<Promise<unknown>>(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<HTMLFormElement>) => {
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,
@@ -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]);
@@ -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";
@@ -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 {
@@ -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,
+31 -8
View File
@@ -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; }
+34
View File
@@ -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,
+29 -6
View File
@@ -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");
});
+42
View File
@@ -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" });
});
+396
View File
@@ -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" });
});