diff --git a/apps/ui/src/routes/index.tsx b/apps/ui/src/routes/index.tsx
index d02d8e9..a7bbd56 100644
--- a/apps/ui/src/routes/index.tsx
+++ b/apps/ui/src/routes/index.tsx
@@ -1,4 +1,6 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, redirect } from "@tanstack/react-router";
+import { createServerFn } from "@tanstack/react-start";
+import { getRequest } from "@tanstack/react-start/server";
import { FaqsSection } from "../components/faqs-section";
import { FeatureSection } from "../components/feature-section";
@@ -6,7 +8,20 @@ import { Footer } from "../components/footer";
import { Header } from "../components/header";
import { HeroSection } from "../components/hero";
-export const Route = createFileRoute("/")({ component: LandingPage });
+const checkAuth = createServerFn({ method: "GET" }).handler(async () => {
+ const request = getRequest();
+ return !!request.headers.get("x-amzn-oidc-identity");
+});
+
+export const Route = createFileRoute("/")({
+ component: LandingPage,
+ loader: async () => {
+ const isAuthenticated = await checkAuth();
+ if (isAuthenticated) {
+ throw redirect({ to: "/projects" });
+ }
+ },
+});
function LandingPage() {
return (
diff --git a/apps/ui/src/routes/projects.tsx b/apps/ui/src/routes/projects.tsx
index 818e761..7d82896 100644
--- a/apps/ui/src/routes/projects.tsx
+++ b/apps/ui/src/routes/projects.tsx
@@ -1,9 +1,17 @@
-import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { Link } from "@tanstack/react-router";
+import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { addTrackPair, type EditableTrack } from "@tooscut/render-engine";
-import { useLiveQuery } from "dexie-react-hooks";
-import { Plus, Trash2, Film, Clock, Monitor, TriangleAlert, Smartphone } from "lucide-react";
-import { useState, useRef, useEffect } from "react";
+import {
+ Archive,
+ Clock,
+ Film,
+ Monitor,
+ Plus,
+ RotateCcw,
+ Smartphone,
+ Trash2,
+ TriangleAlert,
+} from "lucide-react";
+import { useEffect, useRef, useState } from "react";
import { LogoIcon } from "../components/logo";
import { Button } from "../components/ui/button";
@@ -23,16 +31,39 @@ import {
EmptyMedia,
EmptyTitle,
} from "../components/ui/empty";
-import { db, type LocalProject } from "../state/db";
+import {
+ createProject,
+ deleteProject,
+ getMe,
+ listActiveProjects,
+ listArchivedProjects,
+ setProjectArchived,
+ upsertProject,
+ type ProjectRow,
+} from "../lib/project-api";
-export const Route = createFileRoute("/projects")({ component: ProjectChooser });
+export const Route = createFileRoute("/projects")({
+ component: ProjectChooser,
+ loader: async () => {
+ const [me, active, archived] = (await Promise.all([
+ getMe(),
+ listActiveProjects(),
+ listArchivedProjects(),
+ ])) as [
+ { sub: string; name: string; email: string },
+ ProjectRow[],
+ ProjectRow[],
+ ];
+ return { me, active, archived };
+ },
+});
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
-function formatDate(timestamp: number): string {
- const date = new Date(timestamp);
+function formatDate(value: string): string {
+ const date = new Date(value);
const now = new Date();
const diff = now.getTime() - date.getTime();
@@ -47,19 +78,19 @@ function formatDate(timestamp: number): string {
function isChromiumBrowser(): boolean {
if (typeof navigator === "undefined") return true;
const ua = navigator.userAgent;
- // Chrome/Chromium-based browsers have "Chrome/" in UA but not "Edg/" false positive — Edge is also Chromium
- // Key non-Chromium browsers: Firefox (Gecko), Safari (without Chrome token)
const hasChrome = /Chrome\//.test(ua);
const isFirefox = /Firefox\//.test(ua);
- // Safari has "Safari/" but NOT "Chrome/" in the UA
const isSafariOnly = /Safari\//.test(ua) && !hasChrome;
return hasChrome || (!isFirefox && !isSafariOnly);
}
function ProjectChooser() {
+ const { me, active, archived } = Route.useLoaderData();
+ const router = useRouter();
const navigate = useNavigate();
- const projects = useLiveQuery(() => db.projects.orderBy("updatedAt").reverse().toArray());
- const [deleteTarget, setDeleteTarget] = useState
(null);
+
+ const [tab, setTab] = useState<"active" | "archived">("active");
+ const [deleteTarget, setDeleteTarget] = useState(null);
const [showBrowserWarning, setShowBrowserWarning] = useState(() => !isChromiumBrowser());
const [showMobileWarning, setShowMobileWarning] = useState(
() =>
@@ -76,68 +107,75 @@ function ProjectChooser() {
const audioTrackId2 = generateId();
const { tracks } = addTrackPair(tracks1, videoTrackId2, audioTrackId2);
- const project: LocalProject = {
- id,
- name: "Untitled Project",
- settings: { width: 1920, height: 1080, fps: { numerator: 30, denominator: 1 } },
- content: {
- tracks,
- clips: [],
- assets: [],
+ await createProject({
+ data: {
+ id,
+ name: "Untitled Project",
+ settings: { width: 1920, height: 1080, fps: { numerator: 30, denominator: 1 } },
+ content: {
+ tracks,
+ clips: [],
+ crossTransitions: [],
+ assets: [],
+ },
},
- thumbnailDataUrl: null,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- };
+ });
- await db.projects.add(project);
void navigate({
to: "/editor/$projectId",
params: { projectId: id },
- search: { new: true } as any, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
+ search: { new: true } as never,
});
};
+ const handleArchive = async (id: string) => {
+ await setProjectArchived({ data: { id, archived: true } });
+ await router.invalidate();
+ setTab("active");
+ };
+
+ const handleUnarchive = async (id: string) => {
+ await setProjectArchived({ data: { id, archived: false } });
+ await router.invalidate();
+ setTab("archived");
+ };
+
const handleConfirmDelete = async () => {
if (!deleteTarget) return;
- const assetIds = deleteTarget.content.assets.map((a) => a.id);
- await db.projects.delete(deleteTarget.id);
- if (assetIds.length > 0) {
- await db.fileHandles.bulkDelete(assetIds);
- }
+ await deleteProject({ data: deleteTarget.id });
setDeleteTarget(null);
+ await router.invalidate();
};
const handleOpenProject = (projectId: string) => {
void navigate({
to: "/editor/$projectId",
params: { projectId },
- search: { new: false } as any, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
+ search: { new: false } as never,
});
};
- if (projects === undefined) {
- return (
-
- );
- }
+ const projects = tab === "active" ? active : archived;
return (
-
+
void navigate({ to: "/" })}
+ >
Tooscut
-
- {projects.length > 0 && (
+
+
+ {me.name ?
{me.name} : null}
void handleCreateProject()} size="sm">
New Project
- )}
+
@@ -169,7 +207,7 @@ function ProjectChooser() {
Browser not fully supported
This editor relies on WebGPU for rendering, which currently works best in Chrome or
- other Chromium-based browsers. You may experience issues in your current browser.
+ other Chromium-based browsers.
)}
- {projects.length > 0 && (
-
-
Projects
-
- {projects.length} project{projects.length === 1 ? "" : "s"}
-
-
- )}
+
+ setTab("active")}
+ >
+ Active ({active.length})
+
+ setTab("archived")}
+ >
+ Archived ({archived.length})
+
+
{projects.length === 0 ? (
-
+ {tab === "active" ? : }
- No projects yet
+ {tab === "active" ? "No active projects" : "No archived projects"}
- Create your first project to start editing video.
+ {tab === "active"
+ ? "Create your first project to start editing video."
+ : "Archived projects will appear here."}
-
- void handleCreateProject()}>
-
- New Project
-
-
+ {tab === "active" ? (
+
+ void handleCreateProject()}>
+
+ New Project
+
+
+ ) : null}
) : (
@@ -217,7 +275,10 @@ function ProjectChooser() {
))}
@@ -249,12 +310,18 @@ function ProjectChooser() {
function ProjectCard({
project,
+ isArchived,
onOpen,
+ onArchive,
+ onUnarchive,
onDelete,
}: {
- project: LocalProject;
+ project: ProjectRow;
+ isArchived: boolean;
onOpen: (id: string) => void;
- onDelete: (project: LocalProject) => void;
+ onArchive: (id: string) => void;
+ onUnarchive: (id: string) => void;
+ onDelete: (project: ProjectRow) => void;
}) {
return (
- {project.thumbnailDataUrl ? (
+ {project.thumbnail ? (
@@ -273,17 +340,47 @@ function ProjectCard({
)}
-
{
- e.stopPropagation();
- onDelete(project);
- }}
- >
-
-
+
+
+ {isArchived ? (
+
{
+ e.stopPropagation();
+ onUnarchive(project.id);
+ }}
+ >
+
+
+ ) : (
+
{
+ e.stopPropagation();
+ onArchive(project.id);
+ }}
+ >
+
+
+ )}
+ {isArchived ? (
+
{
+ e.stopPropagation();
+ onDelete(project);
+ }}
+ >
+
+
+ ) : null}
+
@@ -291,23 +388,48 @@ function ProjectCard({
- {formatDate(project.updatedAt)}
-
-
-
- {project.settings.width}x{project.settings.height}
+ {formatDate(project.updated_at)}
+
);
}
-function ProjectName({ project }: { project: LocalProject }) {
+function ProjectResolution({ settings }: { settings: unknown }) {
+ const width =
+ typeof settings === "object" && settings !== null && "width" in settings
+ ? Number((settings as { width?: unknown }).width)
+ : null;
+ const height =
+ typeof settings === "object" && settings !== null && "height" in settings
+ ? Number((settings as { height?: unknown }).height)
+ : null;
+
+ if (!width || !height) {
+ return null;
+ }
+
+ return (
+