mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 06:46:09 +00:00
Add project dashboard and Raw library with consistent Solar UI
This commit is contained in:
@@ -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 |
Vendored
+18
-2
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 what’s next.</text></svg>
|
||||
|
After Width: | Height: | Size: 660 B |
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
Reference in New Issue
Block a user