feat(editor): add project browser and whisper selection flows

This commit is contained in:
webadderall
2026-03-23 19:22:39 +11:00
parent 0db229d576
commit e8a8eae2d2
6 changed files with 1525 additions and 483 deletions
+33
View File
@@ -197,6 +197,7 @@ interface Window {
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
thumbnailDataUrl?: string | null,
) => Promise<{
success: boolean;
path?: string;
@@ -220,6 +221,38 @@ interface Window {
canceled?: boolean;
error?: string;
}>;
getProjectsDirectory: () => Promise<{
success: boolean;
path?: string;
error?: string;
}>;
listProjectFiles: () => Promise<{
success: boolean;
projectsDir?: string | null;
entries: Array<{
path: string;
name: string;
updatedAt: number;
thumbnailPath: string | null;
isCurrent: boolean;
isInProjectsDirectory: boolean;
}>;
error?: string;
}>;
openProjectFileAtPath: (filePath: string) => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
openProjectsDirectory: () => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
+25 -2
View File
@@ -209,8 +209,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
deleteRecordingFile: (filePath: string) => {
return ipcRenderer.invoke("delete-recording-file", filePath);
},
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
thumbnailDataUrl?: string | null,
) => {
return ipcRenderer.invoke(
"save-project-file",
projectData,
suggestedName,
existingProjectPath,
thumbnailDataUrl,
);
},
loadProjectFile: () => {
return ipcRenderer.invoke("load-project-file");
@@ -218,6 +229,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
loadCurrentProjectFile: () => {
return ipcRenderer.invoke("load-current-project-file");
},
getProjectsDirectory: () => {
return ipcRenderer.invoke("get-projects-directory");
},
listProjectFiles: () => {
return ipcRenderer.invoke("list-project-files");
},
openProjectFileAtPath: (filePath: string) => {
return ipcRenderer.invoke("open-project-file-at-path", filePath);
},
openProjectsDirectory: () => {
return ipcRenderer.invoke("open-projects-directory");
},
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-load-project", listener);
@@ -0,0 +1,187 @@
import { FolderOpen, Save } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toFileUrl } from "./projectPersistence";
export type ProjectLibraryEntry = {
path: string;
name: string;
updatedAt: number;
thumbnailPath: string | null;
isCurrent: boolean;
isInProjectsDirectory: boolean;
};
type ProjectBrowserDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
entries: ProjectLibraryEntry[];
projectsDirectoryPath: string | null;
onOpenProject: (projectPath: string) => void;
onBrowseProjectFiles: () => void;
onOpenProjectsFolder: () => void;
onSaveProjectAs: () => void;
};
function formatUpdatedAt(updatedAt: number) {
try {
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(updatedAt);
} catch {
return new Date(updatedAt).toLocaleString();
}
}
export default function ProjectBrowserDialog({
open,
onOpenChange,
entries,
projectsDirectoryPath,
onOpenProject,
onBrowseProjectFiles,
onOpenProjectsFolder,
onSaveProjectAs,
}: ProjectBrowserDialogProps) {
const visibleEntries = useMemo(() => entries.slice(0, 16), [entries]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl border-white/10 bg-[#131317] p-0 text-slate-200 shadow-2xl">
<DialogHeader className="border-b border-white/10 px-6 py-5">
<DialogTitle className="text-xl font-semibold text-white">Projects</DialogTitle>
<DialogDescription className="text-sm text-slate-400">
Open recent Recordly projects from one place. New projects are saved to the dedicated
projects folder by default.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-6 py-4">
<div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-slate-500">
Projects Folder
</div>
<div className="truncate text-sm text-slate-300">
{projectsDirectoryPath ?? "Loading projects folder..."}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onOpenProjectsFolder}
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10 hover:text-white"
>
<FolderOpen className="h-4 w-4" />
Open Folder
</Button>
<Button
type="button"
variant="outline"
onClick={onBrowseProjectFiles}
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10 hover:text-white"
>
<FolderOpen className="h-4 w-4" />
Browse Files
</Button>
<Button
type="button"
onClick={onSaveProjectAs}
className="bg-[#2563EB] text-white hover:bg-[#2563EB]/90"
>
<Save className="h-4 w-4" />
Save As
</Button>
</div>
</div>
<div className="max-h-[70vh] overflow-y-auto px-6 py-5">
{visibleEntries.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleEntries.map((entry) => {
const thumbnailSrc = entry.thumbnailPath ? toFileUrl(entry.thumbnailPath) : null;
return (
<button
key={entry.path}
type="button"
onClick={() => onOpenProject(entry.path)}
className="group flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] text-left transition hover:border-[#2563EB]/60 hover:bg-white/[0.05]"
>
<div className="relative aspect-video w-full overflow-hidden bg-[#0d0d11]">
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt=""
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(37,99,235,0.3),_transparent_55%),linear-gradient(180deg,_rgba(255,255,255,0.06),_rgba(255,255,255,0.02))] text-sm font-medium text-slate-400">
No preview yet
</div>
)}
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-3 py-3">
<span className="rounded-full bg-black/50 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-slate-200 backdrop-blur">
{entry.isInProjectsDirectory ? "Library" : "Recent"}
</span>
{entry.isCurrent ? (
<span className="rounded-full bg-[#2563EB]/90 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-white">
Current
</span>
) : null}
</div>
</div>
<div className="flex flex-1 flex-col gap-2 px-4 py-4">
<div className="truncate text-base font-semibold text-white">
{entry.name}
</div>
<div className="truncate text-xs text-slate-500">{entry.path}</div>
<div className="text-xs text-slate-400">
Updated {formatUpdatedAt(entry.updatedAt)}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-8 text-center">
<div className="text-lg font-semibold text-white">No saved projects yet</div>
<div className="max-w-md text-sm text-slate-400">
Save a project and Recordly will keep it in the projects folder with a preview
thumbnail so it is easy to reopen later.
</div>
<div className="flex items-center gap-2">
<Button
type="button"
onClick={onSaveProjectAs}
className="bg-[#2563EB] text-white hover:bg-[#2563EB]/90"
>
<Save className="h-4 w-4" />
Save Project
</Button>
<Button
type="button"
variant="outline"
onClick={onBrowseProjectFiles}
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10 hover:text-white"
>
<FolderOpen className="h-4 w-4" />
Browse Existing
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
import { fromFileUrl } from "./projectPersistence";
type AutoCaptionSourceOptions = {
videoSourcePath?: string | null;
videoPath?: string | null;
recordingSessionVideoPath?: string | null;
currentVideoPath?: string | null;
};
export function resolveAutoCaptionSourcePath(options: AutoCaptionSourceOptions): string | null {
if (options.videoSourcePath) {
return options.videoSourcePath;
}
if (options.videoPath) {
return fromFileUrl(options.videoPath);
}
if (options.recordingSessionVideoPath) {
return fromFileUrl(options.recordingSessionVideoPath);
}
if (options.currentVideoPath) {
return fromFileUrl(options.currentVideoPath);
}
return null;
}