diff --git a/.gitignore b/.gitignore index adaed041a7..7eb98bfed1 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,6 @@ templates/*/.env.local templates/*/.env.*.local test-reports/ *.tsbuildinfo + +# tsup transient bundled config artifacts +tsup.config.bundled_*.mjs diff --git a/templates/assets/actions/list-draft-assets.spec.ts b/templates/assets/actions/list-draft-assets.spec.ts new file mode 100644 index 0000000000..67acbce338 --- /dev/null +++ b/templates/assets/actions/list-draft-assets.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import action from "./list-draft-assets.js"; + +describe("list-draft-assets schema", () => { + it("defaults to no filters when given an empty object", () => { + const parsed = action.schema.parse({}); + expect(parsed.libraryId).toBeUndefined(); + expect(parsed.limit).toBeUndefined(); + }); + + it("coerces a numeric string limit", () => { + const parsed = action.schema.parse({ limit: "5" }); + expect(parsed.limit).toBe(5); + }); + + it("rejects an out-of-range limit", () => { + expect(() => action.schema.parse({ limit: 0 })).toThrow(); + expect(() => action.schema.parse({ limit: 999 })).toThrow(); + }); +}); diff --git a/templates/assets/actions/list-draft-assets.ts b/templates/assets/actions/list-draft-assets.ts new file mode 100644 index 0000000000..2c61d94d2f --- /dev/null +++ b/templates/assets/actions/list-draft-assets.ts @@ -0,0 +1,49 @@ +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(500).optional(), + }), + http: { method: "GET" }, + readOnly: true, + run: async ({ libraryId, limit }) => { + const db = getDb(); + const libraryFilters = [ + accessFilter(schema.assetLibraries, schema.assetLibraryShares), + isNull(schema.assetLibraries.archivedAt), + ]; + if (libraryId) libraryFilters.push(eq(schema.assetLibraries.id, libraryId)); + const accessibleLibraries = await db + .select({ id: schema.assetLibraries.id }) + .from(schema.assetLibraries) + .where(and(...libraryFilters)); + const libraryIds = accessibleLibraries.map((row) => row.id); + 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..8ed2a8e4ff --- /dev/null +++ b/templates/assets/app/components/create/RecentDraftsSection.tsx @@ -0,0 +1,95 @@ +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 ( +