From dd55159e680b02e55a679faf87fb533cdb8af927 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 16 Jun 2026 08:26:29 +0000 Subject: [PATCH 1/6] Add recent drafts section and library drafts tab --- templates/assets/actions/list-draft-assets.ts | 47 ++++++++++ .../components/create/RecentDraftsSection.tsx | 94 +++++++++++++++++++ templates/assets/app/routes/_index.tsx | 3 + templates/assets/app/routes/library.tsx | 31 +++++- 4 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 templates/assets/actions/list-draft-assets.ts create mode 100644 templates/assets/app/components/create/RecentDraftsSection.tsx diff --git a/templates/assets/actions/list-draft-assets.ts b/templates/assets/actions/list-draft-assets.ts new file mode 100644 index 0000000000..9334835e48 --- /dev/null +++ b/templates/assets/actions/list-draft-assets.ts @@ -0,0 +1,47 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; +import { accessFilter } from "@agent-native/core/sharing"; +import { getDb, schema } from "../server/db/index.js"; +import { serializeAsset } from "./_helpers.js"; + +export default defineAction({ + description: + "List unsaved draft generations (generated candidate assets) across accessible libraries, newest first.", + schema: z.object({ + libraryId: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }), + http: { method: "GET" }, + readOnly: true, + run: async ({ libraryId, limit }) => { + const db = getDb(); + const accessibleLibraries = await db + .select({ id: schema.assetLibraries.id }) + .from(schema.assetLibraries) + .where( + and( + accessFilter(schema.assetLibraries, schema.assetLibraryShares), + isNull(schema.assetLibraries.archivedAt), + ), + ); + let libraryIds = accessibleLibraries.map((row) => row.id); + if (libraryId) libraryIds = libraryIds.filter((id) => id === libraryId); + if (!libraryIds.length) return { count: 0, assets: [] }; + + const rows = await db + .select() + .from(schema.assets) + .where( + and( + inArray(schema.assets.libraryId, libraryIds), + eq(schema.assets.role, "generated"), + eq(schema.assets.status, "candidate"), + ), + ) + .orderBy(desc(schema.assets.createdAt)) + .limit(limit ?? 50); + + return { count: rows.length, assets: rows.map((row) => serializeAsset(row)) }; + }, +}); diff --git a/templates/assets/app/components/create/RecentDraftsSection.tsx b/templates/assets/app/components/create/RecentDraftsSection.tsx new file mode 100644 index 0000000000..576a440a39 --- /dev/null +++ b/templates/assets/app/components/create/RecentDraftsSection.tsx @@ -0,0 +1,94 @@ +import { Link } from "react-router"; +import { IconArrowUpRight, IconPhoto } from "@tabler/icons-react"; +import { useActionQuery } from "@agent-native/core/client"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +type DraftAsset = { + id: string; + title?: string | null; + prompt?: string | null; + mediaType?: string | null; + mimeType?: string | null; + thumbnailUrl?: string | null; + previewUrl?: string | null; + url?: string | null; +}; + +const RECENT_DRAFTS_LIMIT = 5; + +export function RecentDraftsSection() { + const { data, isLoading } = useActionQuery("list-draft-assets", { + limit: RECENT_DRAFTS_LIMIT, + }); + const drafts = ((data as any)?.assets ?? []) as DraftAsset[]; + + if (!isLoading && drafts.length === 0) return null; + + return ( +
+
+

Recent Drafts

+ +
+ +
+ {isLoading + ? Array.from({ length: RECENT_DRAFTS_LIMIT }).map((_, index) => ( + + )) + : drafts.map((draft) => ( + +
+ +
+ + ))} +
+
+ ); +} + +function DraftThumbnail({ draft }: { draft: DraftAsset }) { + const isVideo = + draft.mediaType === "video" || draft.mimeType?.startsWith("video/"); + const source = draft.thumbnailUrl ?? draft.previewUrl ?? draft.url ?? ""; + + if (isVideo && !draft.thumbnailUrl) { + return ( +