diff --git a/AGENTS.md b/AGENTS.md index ff44cac0..b3e4ae36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -748,6 +748,7 @@ Plugin UI pages are distributed as a shadcn v4 registry so consumers can eject a ### Key design rules - **Hooks are excluded** from the registry. Components import hooks from `@btst/stack/plugins/{name}/client/hooks`. Only the view layer is ejectable. +- **Routable plugin pages should be wired back in via `pageComponents`** on the client plugin config when the plugin supports page overrides. If a plugin intentionally does not support `pageComponents`, document the direct-import rendering pattern clearly in the plugin docs and shared shadcn registry guide. - **`@workspace/ui` imports are rewritten**: standard shadcn components → `registryDependencies`; custom components (`page-wrapper`, `empty`, etc.) → embedded as `registry:component` files from `packages/ui/src/`; multi-file components (`auto-form`, `minimal-tiptap`, `ui-builder`) → external registry URL in `registryDependencies`. - **Directory structure is preserved**: source files land at `src/components/btst/{name}/client/{relative}` so all relative imports remain valid with no rewriting. - **`EXTERNAL_REGISTRY_COMPONENTS`** in `build-registry.ts` maps directory-based workspace/ui components to their external registry URLs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 595bc5fe..4aa09ce2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -792,6 +792,8 @@ npx shadcn@latest add "https://raw.githubusercontent.com/better-stack-ai/better- Files are installed into `src/components/btst/{plugin}/client/` with all relative imports preserved. Data-fetching hooks remain in `@btst/stack`. +When a plugin exposes `pageComponents` on its client config, wire the ejected routable pages back in through that option. If a plugin intentionally does not support `pageComponents`, document the direct-import rendering pattern clearly in the plugin docs and the shared shadcn registry guide. + ### Rebuild the registry locally ```bash diff --git a/README.md b/README.md index d3d6c09e..9c7af8c6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Enable the features you need and keep building your product. | **Form Builder** | Dynamic form builder with drag-and-drop editor, submissions, and validation | | **UI Builder** | Visual drag-and-drop page builder with component registry and public rendering | | **Kanban** | Project management with boards, columns, tasks, drag-and-drop, and priority levels | +| **Media** | Media library with uploads, folders, picker UI, URL registration, and reusable image inputs | | **OpenAPI** | Auto-generated API documentation with interactive Scalar UI | | **Route Docs** | Auto-generated client route documentation with interactive navigation | | **Better Auth UI** | Beautiful shadcn/ui authentication components for better-auth | @@ -121,8 +122,8 @@ Supports Prisma, Drizzle, MongoDB and Kysely SQL dialects. Each plugin's UI layer is available as a [shadcn registry](https://ui.shadcn.com/docs/registry) block. Use it to **eject and fully customize** the page components while keeping all data-fetching and API logic from `@btst/stack`: ```bash -# Install a single plugin's UI -npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-blog.json +# Install a single plugin's UI (for example, Media) +npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json # Or install the full collection npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/registry.json diff --git a/docs/assets/media-demo-1.png b/docs/assets/media-demo-1.png new file mode 100644 index 00000000..cce759c1 Binary files /dev/null and b/docs/assets/media-demo-1.png differ diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 02f60719..5e3d0c70 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -18,6 +18,7 @@ "plugins/ui-builder", "plugins/kanban", "plugins/comments", + "plugins/media", "plugins/open-api", "plugins/route-docs", "plugins/better-auth-ui", diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index 53043a15..3779fad2 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -4,7 +4,7 @@ description: Available plugins and features for BTST --- import { Card, Cards } from "fumadocs-ui/components/card"; -import { BookOpen, Database, Hammer, Bot, Shield, FileText, FileCode, Route, Layout, Columns3 } from "lucide-react"; +import { BookOpen, Database, Hammer, Bot, Shield, FileText, FileCode, Route, Layout, Columns3, MessageSquare, ImageIcon } from "lucide-react"; BTST provides a collection of full-stack plugins that you can easily integrate into your React application. Each plugin includes routes, APIs, database schemas, components, and hooks—everything you need to add complete features to your app. @@ -48,6 +48,18 @@ With more plugins coming soon, you can add complete features to your app in minu icon={} description="Project management with boards, columns, tasks, drag-and-drop, and priority levels." /> + } + description="Threaded comments with moderation, likes, replies, and embeddable comment threads." + /> + } + description="Media library with uploads, folders, picker UI, URL registration, and reusable image inputs." + /> + + Media Plugin Demo - Media library picker + + + +The Media plugin gives you a built-in media library with folders, uploads, URL-based asset registration, and reusable picker components that can be embedded anywhere in your app. It works well as a standalone `/media` library route and as shared infrastructure for other plugins such as Blog, CMS, and Kanban. + +## Installation + + +Ensure you followed the general [framework installation guide](/installation) first. + + +Follow these steps to add the Media plugin to your BTST setup. + +### 1. Add Plugin to Backend API + +Import and register the media backend plugin in your `stack.ts` file: + +```ts title="lib/stack.ts" +import { stack } from "@btst/stack" +import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api" + +const { handler, dbSchema } = stack({ + basePath: "/api/data", + plugins: { + media: mediaBackendPlugin({ + storageAdapter: localAdapter(), + maxFileSizeBytes: 10 * 1024 * 1024, + allowedMimeTypes: ["image/*", "application/pdf"], + }), + }, + adapter: (db) => createPrismaAdapter(prisma, db, { + provider: "postgresql", + }), +}) + +export { handler, dbSchema } +``` + +The `mediaBackendPlugin()` requires a `storageAdapter`. BTST currently ships with three modes: + +- `localAdapter()` for local filesystem uploads and self-hosted setups +- `s3Adapter()` for S3-compatible object storage using presigned uploads +- `vercelBlobAdapter()` for direct uploads to Vercel Blob + + +Pick the backend storage adapter first, then make the client-side `uploadMode` match it. A mismatch between the two is the most common Media plugin integration mistake. + + +### 2. Add Plugin to Client + +Register the media client plugin in your `stack-client.tsx` file: + +```tsx title="lib/stack-client.tsx" +import { createStackClient } from "@btst/stack/client" +import { mediaClientPlugin } from "@btst/stack/plugins/media/client" +import { QueryClient } from "@tanstack/react-query" + +const getBaseURL = () => + process.env.BASE_URL || "http://localhost:3000" + +export const getStackClient = (queryClient: QueryClient) => { + const baseURL = getBaseURL() + + return createStackClient({ + plugins: { + media: mediaClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient, + }), + }, + }) +} +``` + +**Required configuration:** +- `apiBaseURL`: Base URL for server-side API requests +- `apiBasePath`: Path where your BTST API is mounted +- `siteBaseURL`: Base URL of your site for route metadata +- `siteBasePath`: Path where your BTST pages are mounted +- `queryClient`: React Query client used for prefetching and caching + + +The media client plugin registers the `/media` page route and prefetches the initial asset grid and folder tree during SSR. It expects the same API base settings you use elsewhere in your BTST client setup. + + +### 3. Import Plugin CSS + +Add the media plugin CSS to your global stylesheet: + +```css title="app/globals.css" +@import "@btst/stack/plugins/media/css"; +``` + +This includes the built-in media library UI, picker layout, folder tree, upload states, and image previews. + +### 4. Add Context Overrides + +Configure framework-specific overrides in your `StackProvider`: + + + + ```tsx title="app/pages/layout.tsx" + import { StackProvider } from "@btst/stack/context" + import { uploadAsset, type MediaPluginOverrides } from "@btst/stack/plugins/media/client" + import Link from "next/link" + import Image from "next/image" + import { useRouter } from "next/navigation" + import { QueryClient } from "@tanstack/react-query" + + const getBaseURL = () => + typeof window !== "undefined" + ? process.env.NEXT_PUBLIC_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:3000" + + type PluginOverrides = { + media: MediaPluginOverrides + } + + export default function Layout({ children }) { + const router = useRouter() + const queryClient = new QueryClient() + const baseURL = getBaseURL() + + return ( + + basePath="/pages" + overrides={{ + media: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + queryClient, + uploadMode: "direct", + navigate: (path) => router.push(path), + Link: ({ href, ...props }) => , + Image: (props) => , + }, + }} + > + {children} + + ) + } + ``` + + + + ```tsx title="app/routes/pages/_layout.tsx" + import { Outlet, Link, useNavigate } from "react-router" + import { StackProvider } from "@btst/stack/context" + import type { MediaPluginOverrides } from "@btst/stack/plugins/media/client" + import { QueryClient } from "@tanstack/react-query" + + const getBaseURL = () => + typeof window !== "undefined" + ? import.meta.env.VITE_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:5173" + + type PluginOverrides = { + media: MediaPluginOverrides + } + + export default function Layout() { + const navigate = useNavigate() + const queryClient = new QueryClient() + const baseURL = getBaseURL() + + return ( + + basePath="/pages" + overrides={{ + media: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + queryClient, + uploadMode: "direct", + navigate: (href) => navigate(href), + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + }, + }} + > + + + ) + } + ``` + + + + ```tsx title="src/routes/pages/route.tsx" + import { StackProvider } from "@btst/stack/context" + import type { MediaPluginOverrides } from "@btst/stack/plugins/media/client" + import { Link, Outlet, useRouter } from "@tanstack/react-router" + import { QueryClient } from "@tanstack/react-query" + + const getBaseURL = () => + typeof window !== "undefined" + ? import.meta.env.VITE_BASE_URL || window.location.origin + : process.env.BASE_URL || "http://localhost:3000" + + type PluginOverrides = { + media: MediaPluginOverrides + } + + function Layout() { + const router = useRouter() + const queryClient = new QueryClient() + const baseURL = getBaseURL() + + return ( + + basePath="/pages" + overrides={{ + media: { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + queryClient, + uploadMode: "direct", + navigate: (href) => router.navigate({ href }), + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + }, + }} + > + + + ) + } + ``` + + + +**Required overrides:** +- `apiBaseURL` +- `apiBasePath` +- `queryClient` +- `navigate` + +**Optional overrides:** +- `uploadMode`: Must match your backend storage adapter +- `Link`: Custom framework-aware link component +- `Image`: Custom image renderer such as Next.js `Image` +- `headers`: Additional request headers +- `imageCompression`: Compression settings or `false` to disable compression +- `onRouteRender`, `onRouteError`, `onBeforeLibraryPageRendered`: Route lifecycle hooks + +### 5. Generate and Apply Database Changes + +The Media plugin adds database tables for assets and folders. Generate and apply your migrations: + +```bash +npx @btst/cli generate +npx @btst/cli migrate +``` + +For more details on the CLI and all available options, see the [CLI documentation](/cli). + +## Congratulations, You're Done! + +Your media plugin is now configured and ready to use. Here is a quick reference of what you get out of the box: + +**Routes** + +| Route | Description | +| --- | --- | +| `/pages/media` | Full media library UI with folders, uploads, URL tab, and asset browsing | + +**Core API endpoints** + +| Method | Endpoint | Purpose | +| --- | --- | --- | +| `GET` | `/media/assets` | List assets with filtering and pagination | +| `POST` | `/media/assets` | Register an existing uploaded asset URL | +| `PATCH` | `/media/assets/:id` | Update asset metadata | +| `DELETE` | `/media/assets/:id` | Delete an asset | +| `GET` | `/media/folders` | List folders | +| `POST` | `/media/folders` | Create a folder | +| `DELETE` | `/media/folders/:id` | Delete a folder | +| `POST` | `/media/upload` | Direct upload endpoint for local storage | +| `POST` | `/media/upload/token` | Presigned upload token endpoint for S3-compatible storage | +| `POST` | `/media/upload/vercel-blob` | Upload handler for Vercel Blob | + +**Reusable UI pieces** + +| Export | Purpose | +| --- | --- | +| `MediaPicker` | Embed the full media browser in your own forms and editors | +| `ImageInputField` | Drop-in image field with preview, change, and remove actions | +| `uploadAsset()` | Imperative upload helper for editors and non-React callbacks | + +## Common Patterns + +### Imperative uploads for editor callbacks + +When you need to upload an image outside React hooks, use `uploadAsset()`: + +```tsx title="app/pages/layout.tsx" +import { uploadAsset } from "@btst/stack/plugins/media/client" + +const mediaClientConfig = { + apiBaseURL: baseURL, + apiBasePath: "/api/data", + uploadMode: "direct" as const, +} + +const uploadImage = async (file: File) => { + const asset = await uploadAsset(mediaClientConfig, { file }) + return asset.url +} +``` + +This is the same pattern used in the example apps to connect Blog, CMS, and Kanban image uploads to the shared Media plugin. + +### Embedding the picker in your own UI + +Use `MediaPicker` when you want a compact "browse media" flow inside a custom form: + +```tsx title="components/image-picker.tsx" +import { MediaPicker } from "@btst/stack/plugins/media/client/components" +import { Button } from "@/components/ui/button" + +export function ImagePicker({ onSelect }: { onSelect: (url: string) => void }) { + return ( + Browse media} + accept={["image/*"]} + onSelect={(assets) => onSelect(assets[0]?.url ?? "")} + /> + ) +} +``` + +### Using the built-in image field + +Use `ImageInputField` when you want a simple image preview and replacement flow without building your own wrapper: + +```tsx title="components/product-image-field.tsx" +import { ImageInputField } from "@btst/stack/plugins/media/client/components" + +export function ProductImageField({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}) { + return +} +``` + +## API Reference + +### Backend (`@btst/stack/plugins/media/api`) + +#### mediaBackendPlugin + + + +#### MediaBackendConfig + +Choose your storage adapter and optional upload constraints: + + + +#### MediaBackendHooks + +Customize backend behavior with optional lifecycle hooks for uploads, listing, folder management, and deletes: + + + +**Example usage:** + +```ts +import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api" + +mediaBackendPlugin({ + storageAdapter: localAdapter(), + hooks: { + onBeforeUpload: async (_meta, context) => { + const session = await getSession(context.headers as Headers) + if (!session?.user?.isAdmin) throw new Error("Admin access required") + }, + onBeforeDelete: async (asset) => { + if (asset.mimeType.startsWith("image/")) return + throw new Error("Only image deletion is allowed here") + }, + }, +}) +``` + +#### MediaApiContext + + + +#### StorageAdapter + + + +#### DirectStorageAdapter + + + +#### S3StorageAdapter + + + +#### VercelBlobStorageAdapter + + + +### Client (`@btst/stack/plugins/media/client`) + +#### mediaClientPlugin + + + +#### MediaClientConfig + +The client plugin accepts the required route, site, and React Query configuration: + + + +#### MediaClientHooks + +These hooks run around the media library SSR loader: + + + +#### MediaLoaderContext + + + +#### MediaPluginOverrides + +Configure framework-specific overrides and route lifecycle hooks: + + + +#### MediaUploadMode + + + +#### MediaRouteContext + + + +#### uploadAsset + + + +#### MediaUploadClientConfig + + + +#### UploadAssetInput + + + +### Components (`@btst/stack/plugins/media/client/components`) + +#### MediaPicker + +The full popover-based media browser with Browse, Upload, and URL tabs: + + + +#### MediaPickerProps + + + +#### ImageInputField + +Use the built-in image preview field when you only need single-image selection: + + + +### Hooks (`@btst/stack/plugins/media/client/hooks`) + +The Media plugin exposes React Query-powered hooks for reading and mutating assets and folders: + +#### useAssets + + + +#### useFolders + + + +#### useUploadAsset + + + +#### useRegisterAsset + + + +#### useDeleteAsset + + + +#### useCreateFolder + + + +#### useDeleteFolder + + + +## Server-side Data Access + +Like other BTST plugins, the Media plugin supports two server-side access patterns: + +1. Use `stack().api.media.*` when you already have a configured stack instance. +2. Import getters and mutations directly from `@btst/stack/plugins/media/api` when you want lower-level access with an adapter. + +**Available getters via `stack().api.media`:** + +| Function | Description | +| --- | --- | +| `listAssets(params?)` | List assets with pagination, search, MIME filtering, and folder filtering | +| `getAssetById(id)` | Fetch a single asset by ID | +| `listFolders(params?)` | List folders, optionally scoped to a parent folder | +| `getFolderById(id)` | Fetch a single folder by ID | + +**Available direct mutations:** + +| Function | Description | +| --- | --- | +| `createAsset(adapter, input)` | Register an asset record | +| `updateAsset(adapter, id, input)` | Update an existing asset | +| `deleteAsset(adapter, id)` | Delete an asset record | +| `createFolder(adapter, input)` | Create a folder | +| `deleteFolder(adapter, id)` | Delete a folder | + + +Authorization hooks are not called when you use `stack().api.media.*` or direct getter and mutation imports. Enforce access control at the call site. + + +### `AssetListParams` + + + +### `AssetListResult` + + + +### `FolderListParams` + + + +### `CreateAssetInput` + + + +### `UpdateAssetInput` + + + +### `CreateFolderInput` + + + +## Types + +#### Asset + + + +#### Folder + + + +#### SerializedAsset + + + +#### SerializedFolder + + + +## Static Site Generation (SSG) + +The Media plugin does not currently support build-time SSG prefetching. Its client loader warns when no server is available at build time, and the library route is intended to run against a live API. + +Use the Media plugin for authenticated dashboards, admin tools, and editor workflows rather than static public pages. + +## Shadcn Registry + +The Media plugin UI layer is distributed as a [shadcn registry](https://ui.shadcn.com/docs/registry) block. Use the registry to **eject and fully customize** the page components while keeping all data-fetching and API logic from `@btst/stack`. + + +The registry installs only the view layer. Hooks and data-fetching continue to come from `@btst/stack/plugins/media/client/hooks`. + + + + + ```bash + npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json + ``` + + + ```bash + pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json + ``` + + + ```bash + bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json + ``` + + + +This copies the media page components into `src/components/btst/media/client/` in your project. All relative imports remain valid and you can edit the files freely while the plugin's data layer stays intact. + +### Using ejected components + +After installing, wire your custom components into the plugin via the `pageComponents` option in your client plugin config: + +```tsx title="lib/stack-client.tsx" +import { mediaClientPlugin } from "@btst/stack/plugins/media/client" +import { LibraryPageComponent } from "@/components/btst/media/client/components/pages/library-page" + +mediaClientPlugin({ + apiBaseURL: "...", + apiBasePath: "/api/data", + siteBaseURL: "...", + siteBasePath: "/pages", + queryClient, + pageComponents: { + library: LibraryPageComponent, // replaces the media library page + }, +}) +``` + +The ejected library page still relies on your `media` `StackProvider` overrides for API configuration, navigation, upload mode, and hooks. diff --git a/docs/content/docs/shadcn-registry.mdx b/docs/content/docs/shadcn-registry.mdx index a23bc2aa..3b228e57 100644 --- a/docs/content/docs/shadcn-registry.mdx +++ b/docs/content/docs/shadcn-registry.mdx @@ -6,7 +6,7 @@ description: Eject and fully customize plugin UI components using the shadcn reg import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; import { Card, Cards } from "fumadocs-ui/components/card"; -import { BookOpen, Bot, Database, FileText, Layout, Columns3 } from "lucide-react"; +import { BookOpen, Bot, Database, FileText, Layout, Columns3, MessageSquare, ImageIcon } from "lucide-react"; Every BTST plugin ships its page components as a [shadcn v4 registry](https://ui.shadcn.com/docs/registry) block. This lets you **eject the entire view layer** into your own codebase and customize it freely — while all data-fetching, API logic, hooks, and routing stay untouched inside `@btst/stack`. @@ -32,6 +32,8 @@ Pick the plugin you want to customize: } description="Form list, editor, submissions pages" /> } description="Page list, page builder editor" /> } description="Boards list, board detail page" /> + } description="Moderation pages, user comments pages, and reusable thread UI" /> + } description="Media library page and reusable picker UI" /> Or install a single plugin's UI directly: @@ -56,6 +58,12 @@ Or install a single plugin's UI directly: # Kanban npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.json + + # Comments + npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-comments.json + + # Media + npx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json ``` @@ -77,6 +85,12 @@ Or install a single plugin's UI directly: # Kanban pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.json + + # Comments + pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-comments.json + + # Media + pnpx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json ``` @@ -98,13 +112,19 @@ Or install a single plugin's UI directly: # Kanban bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-kanban.json + + # Comments + bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-comments.json + + # Media + bunx shadcn@latest add https://github.com/better-stack-ai/better-stack/blob/main/packages/stack/registry/btst-media.json ``` ## Wire up ejected components -After the install, import your ejected components and pass them to the client plugin via `pageComponents`. Any key you omit falls back to the built-in default, so you only need to override the pages you actually want to change. +After the install, most plugins let you import your ejected components and pass them to the client plugin via `pageComponents`. Any key you omit falls back to the built-in default, so you only need to override the pages you actually want to change. ### Blog @@ -221,8 +241,46 @@ kanbanClientPlugin({ }) ``` +### Comments + +Comments is a little different: the ejected UI is typically rendered directly in your app rather than passed through a `pageComponents` option. + +```tsx title="app/comments/moderation/page.tsx" +import { ModerationPageComponent } from "@/components/btst/comments/client/components/pages/moderation-page" + +export default function CommentsModerationPage() { + return +} +``` + +Keep `commentsClientPlugin()` registered and your `comments` overrides configured in `StackProvider`. The ejected page components will continue to use the shared hooks from `@btst/stack/plugins/comments/client/hooks`. + +### Media + +Media uses a single `pageComponents.library` override for the media library page. + +```tsx title="lib/stack-client.tsx" +import { mediaClientPlugin } from "@btst/stack/plugins/media/client" +import { LibraryPageComponent } from "@/components/btst/media/client/components/pages/library-page" + +mediaClientPlugin({ + apiBaseURL: "...", + apiBasePath: "/api/data", + siteBaseURL: "...", + siteBasePath: "/pages", + queryClient, + pageComponents: { + library: LibraryPageComponent, // media library page + }, +}) +``` + +Keep your `media` overrides configured in `StackProvider` so the ejected component can resolve API config, upload mode, and hooks correctly. + ## Available `pageComponents` keys +The table below covers the plugins that currently support `pageComponents` overrides directly. Comments still use the direct-import pattern shown above. + | Plugin | Key | Props | Description | |---|---|---|---| | Blog | `posts` | — | Published posts list | @@ -247,6 +305,7 @@ kanbanClientPlugin({ | Kanban | `boards` | — | Boards list | | Kanban | `newBoard` | — | New board | | Kanban | `board` | `{ boardId: string }` | Board detail | +| Media | `library` | — | Media library page | ## What the registry installs diff --git a/e2e/fixtures/test-image.png b/e2e/fixtures/test-image.png new file mode 100644 index 00000000..e59b3490 Binary files /dev/null and b/e2e/fixtures/test-image.png differ diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 15523229..1471f9b8 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -100,6 +100,7 @@ const allProjects = [ "**/*.ssg.spec.ts", "**/*.page-context.spec.ts", "**/*.wealthreview.spec.ts", + "**/*.media.spec.ts", ], }, }, @@ -166,6 +167,7 @@ export default defineConfig({ actionTimeout: 15_000, navigationTimeout: 30_000, baseURL: "http://localhost:3000", + viewport: { width: 1280, height: 900 }, }, webServer: webServers, projects, diff --git a/e2e/tests/smoke.cms.spec.ts b/e2e/tests/smoke.cms.spec.ts index a970c070..8962584e 100644 --- a/e2e/tests/smoke.cms.spec.ts +++ b/e2e/tests/smoke.cms.spec.ts @@ -1,4 +1,19 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +// Shared test image buffer loaded once for the whole module +const testImageBuffer = readFileSync( + resolve(__dirname, "../fixtures/test-image.png"), +); + +// Ignore network/resource 404s from image thumbnail loading (local adapter +// serves uploads as static Next.js files; the preview may 404 in +// production-mode test runs). Only capture JS runtime errors. +function isRealConsoleError(text: string): boolean { + if (text.startsWith("Failed to load resource:")) return false; + return true; +} const emptySelector = '[data-testid="empty-state"]'; const errorSelector = '[data-testid="error-placeholder"]'; @@ -445,6 +460,49 @@ test.describe("CMS Plugin", () => { }); }); +// ─── MediaPicker helpers (reused across CMS image upload tests) ───────────── + +/** Open the MediaPicker popover via the `open-media-picker` trigger. */ +async function openMediaPicker(page: Page) { + const triggerBtn = page.locator('[data-testid="open-media-picker"]').first(); + await expect(triggerBtn).toBeVisible({ timeout: 10000 }); + await triggerBtn.click(); + await expect(page.getByText("Media Library")).toBeVisible({ timeout: 5000 }); +} + +/** + * Inside an open MediaPicker, switch to the Upload tab and set a test image, + * then switch to Browse to wait for the uploaded asset to appear. + */ +async function uploadInMediaPicker(page: Page) { + await page.getByRole("tab", { name: /upload/i }).click(); + const fileInput = page.locator('[data-testid="media-upload-input"]').first(); + await expect(fileInput).toBeAttached({ timeout: 5000 }); + await fileInput.setInputFiles({ + name: "test-product-image.png", + mimeType: "image/png", + buffer: testImageBuffer, + }); + // Switch to Browse and wait for the uploaded thumbnail to appear + await page.getByRole("tab", { name: /browse/i }).click(); + await expect( + page.locator('[data-testid="media-asset-item"]').first(), + ).toBeVisible({ timeout: 15000 }); +} + +/** Click the first asset in the Browse grid, then confirm selection. */ +async function selectFirstAsset(page: Page) { + await page.locator('[data-testid="media-asset-item"]').first().click(); + const selectBtn = page.locator('[data-testid="media-select-button"]'); + await expect(selectBtn).toBeVisible({ timeout: 3000 }); + await selectBtn.click(); + await expect(page.getByText("Media Library")).not.toBeVisible({ + timeout: 5000, + }); +} + +// ─── CMS Image Upload tests ────────────────────────────────────────────────── + test.describe("CMS Image Upload", () => { // Generate unique ID for each test run to avoid slug collisions const testRunId = Date.now().toString(36); @@ -452,14 +510,15 @@ test.describe("CMS Image Upload", () => { test("image upload field is rendered in product form", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); }); await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); - // Should show the image upload input - const imageUploadInput = page.locator('[data-testid="image-upload-input"]'); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); + // The MediaPicker trigger button should be visible in the image field + const trigger = page.locator('[data-testid="open-media-picker"]').first(); + await expect(trigger).toBeVisible({ timeout: 5000 }); expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( [], @@ -469,33 +528,26 @@ test.describe("CMS Image Upload", () => { test("can upload an image and see preview", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); }); await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); - // Wait for the image upload input to be visible - const imageUploadInput = page.locator('[data-testid="image-upload-input"]'); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); - - // Upload a test image file - const testImageBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - await imageUploadInput.setInputFiles({ - name: "test-product-image.png", - mimeType: "image/png", - buffer: Buffer.from(testImageBase64, "base64"), - }); + // Open the MediaPicker and upload via the Upload tab + await openMediaPicker(page); + await uploadInMediaPicker(page); + await selectFirstAsset(page); - // Wait for the preview to appear + // After selection the image preview should appear const imagePreview = page.locator('[data-testid="image-preview"]'); await expect(imagePreview).toBeVisible({ timeout: 10000 }); - // The preview should show the mock URL (placehold.co/400/png) from the uploadImage override - await expect(imagePreview).toHaveAttribute( - "src", - /placehold\.co|data:image/, - ); + // The preview should show a real URL (not a mock placeholder) + const previewSrc = await imagePreview.getAttribute("src"); + expect(previewSrc).toBeTruthy(); + expect(previewSrc).not.toBe(""); + expect(previewSrc).not.toContain("placehold.co"); expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( [], @@ -505,34 +557,29 @@ test.describe("CMS Image Upload", () => { test("can remove uploaded image", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); }); await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); - // Upload an image first - const imageUploadInput = page.locator('[data-testid="image-upload-input"]'); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); - - const testImageBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - await imageUploadInput.setInputFiles({ - name: "to-remove.png", - mimeType: "image/png", - buffer: Buffer.from(testImageBase64, "base64"), - }); + // Upload via MediaPicker + await openMediaPicker(page); + await uploadInMediaPicker(page); + await selectFirstAsset(page); // Wait for preview const imagePreview = page.locator('[data-testid="image-preview"]'); await expect(imagePreview).toBeVisible({ timeout: 10000 }); // Click remove button - const removeButton = page.locator('[data-testid="remove-image-button"]'); - await removeButton.click(); + await page.locator('[data-testid="remove-image-button"]').click(); - // Preview should be hidden, upload input should reappear + // Preview should be gone; the Browse Media trigger should reappear await expect(imagePreview).not.toBeVisible(); - await expect(imageUploadInput).toBeAttached(); + await expect( + page.locator('[data-testid="open-media-picker"]').first(), + ).toBeVisible(); expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( [], @@ -542,7 +589,8 @@ test.describe("CMS Image Upload", () => { test("create product with image upload", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); }); await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); @@ -561,17 +609,10 @@ test.describe("CMS Image Upload", () => { await page.locator('[role="option"]').first().waitFor({ state: "visible" }); await page.locator('[role="option"]').first().click(); - // Upload an image - const imageUploadInput = page.locator('[data-testid="image-upload-input"]'); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); - - const testImageBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - await imageUploadInput.setInputFiles({ - name: "product-image.png", - mimeType: "image/png", - buffer: Buffer.from(testImageBase64, "base64"), - }); + // Upload an image via MediaPicker + await openMediaPicker(page); + await uploadInMediaPicker(page); + await selectFirstAsset(page); // Wait for preview const imagePreview = page.locator('[data-testid="image-preview"]'); @@ -591,7 +632,8 @@ test.describe("CMS Image Upload", () => { test("edit product preserves uploaded image", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); }); // First create a product with an image @@ -610,17 +652,10 @@ test.describe("CMS Image Upload", () => { await page.locator('[role="option"]').first().waitFor({ state: "visible" }); await page.locator('[role="option"]').first().click(); - // Upload an image - const imageUploadInput = page.locator('[data-testid="image-upload-input"]'); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); - - const testImageBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - await imageUploadInput.setInputFiles({ - name: "original-image.png", - mimeType: "image/png", - buffer: Buffer.from(testImageBase64, "base64"), - }); + // Upload an image via MediaPicker + await openMediaPicker(page); + await uploadInMediaPicker(page); + await selectFirstAsset(page); // Wait for preview let imagePreview = page.locator('[data-testid="image-preview"]'); @@ -638,7 +673,7 @@ test.describe("CMS Image Upload", () => { const row = page.locator(`tr:has-text("${expectedSlug}")`); await row.locator("button:has(svg.lucide-pencil)").click(); - // On edit page, image preview should still be visible + // On edit page, image preview should still be visible (loaded from DB) imagePreview = page.locator('[data-testid="image-preview"]'); await expect(imagePreview).toBeVisible({ timeout: 10000 }); @@ -647,118 +682,3 @@ test.describe("CMS Image Upload", () => { ); }); }); - -test.describe("CMS Custom Field Components", () => { - test("uses custom file field component from fieldComponents override", async ({ - page, - }) => { - const errors: string[] = []; - page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); - }); - - await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); - - // All examples have a custom file field component with data-testid="custom-file-field" - const customFileField = page.locator('[data-testid="custom-file-field"]'); - await expect(customFileField).toBeVisible({ timeout: 5000 }); - - // Verify the custom component has the image upload input - const imageUploadInput = customFileField.locator( - '[data-testid="image-upload-input"]', - ); - await expect(imageUploadInput).toBeAttached(); - - expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( - [], - ); - }); - - test("custom file component can upload and preview image", async ({ - page, - }) => { - const errors: string[] = []; - page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); - }); - - await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); - - // Find the custom file field - const customFileField = page.locator('[data-testid="custom-file-field"]'); - await expect(customFileField).toBeVisible({ timeout: 5000 }); - - // Upload an image using the custom component - const imageUploadInput = customFileField.locator( - '[data-testid="image-upload-input"]', - ); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); - - const testImageBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - await imageUploadInput.setInputFiles({ - name: "custom-upload.png", - mimeType: "image/png", - buffer: Buffer.from(testImageBase64, "base64"), - }); - - // Wait for preview to appear in the custom component - const imagePreview = customFileField.locator( - '[data-testid="image-preview"]', - ); - await expect(imagePreview).toBeVisible({ timeout: 10000 }); - - // Verify the mock URL is used (placehold.co from mockUploadFile) - await expect(imagePreview).toHaveAttribute("src", /placehold\.co/); - - expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( - [], - ); - }); - - test("custom file component can remove uploaded image", async ({ page }) => { - const errors: string[] = []; - page.on("console", (msg) => { - if (msg.type() === "error") errors.push(msg.text()); - }); - - await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); - - const customFileField = page.locator('[data-testid="custom-file-field"]'); - await expect(customFileField).toBeVisible({ timeout: 5000 }); - - // Upload an image - const imageUploadInput = customFileField.locator( - '[data-testid="image-upload-input"]', - ); - await expect(imageUploadInput).toBeAttached({ timeout: 5000 }); - - const testImageBase64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - await imageUploadInput.setInputFiles({ - name: "to-remove.png", - mimeType: "image/png", - buffer: Buffer.from(testImageBase64, "base64"), - }); - - // Wait for preview - const imagePreview = customFileField.locator( - '[data-testid="image-preview"]', - ); - await expect(imagePreview).toBeVisible({ timeout: 10000 }); - - // Click remove button - const removeButton = customFileField.locator( - '[data-testid="remove-image-button"]', - ); - await removeButton.click(); - - // Preview should be hidden, upload input should reappear - await expect(imagePreview).not.toBeVisible(); - await expect(imageUploadInput).toBeAttached(); - - expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual( - [], - ); - }); -}); diff --git a/e2e/tests/smoke.media.spec.ts b/e2e/tests/smoke.media.spec.ts new file mode 100644 index 00000000..711e3d2c --- /dev/null +++ b/e2e/tests/smoke.media.spec.ts @@ -0,0 +1,317 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "fs"; +import { resolve } from "path"; + +// Load the test image from fixtures once +const testImageBuffer = readFileSync( + resolve(__dirname, "../fixtures/test-image.png"), +); + +// Filter function for console errors: ignore network/resource 404s from image +// thumbnail loading (expected with local adapter in Next.js production mode). +// Only capture JS runtime errors. +function isRealConsoleError(text: string): boolean { + if (text.startsWith("Failed to load resource:")) return false; + return true; +} + +// Helper: open media picker popover in the page +async function openMediaPicker(page: Page) { + const triggerBtn = page.locator('[data-testid="open-media-picker"]').first(); + await expect(triggerBtn).toBeVisible({ timeout: 10000 }); + await triggerBtn.click(); + // Wait for the popover content to appear (Media Library header) + await expect(page.getByText("Media Library")).toBeVisible({ timeout: 5000 }); +} + +// Helper: upload a file inside the open MediaPicker (Upload tab) +async function uploadInMediaPicker(page: Page) { + // The blog editor opens the picker lower on the page in Next.js, so the + // tab strip can render partially offscreen at the configured viewport. + const uploadTab = page.getByRole("tab", { name: /upload/i }).last(); + await uploadTab.scrollIntoViewIfNeeded(); + await uploadTab.click(); + + // Find the hidden file input inside the upload tab + const fileInput = page.locator('[data-testid="media-upload-input"]').first(); + await expect(fileInput).toBeAttached({ timeout: 5000 }); + await fileInput.setInputFiles({ + name: "test-image.png", + mimeType: "image/png", + buffer: testImageBuffer, + }); + + // Wait for upload to complete — a thumbnail should appear in the Browse tab + const browseTab = page.getByRole("tab", { name: /browse/i }).last(); + await browseTab.scrollIntoViewIfNeeded(); + await browseTab.click(); + // The uploaded asset should appear in the grid + await expect( + page.locator('[data-testid="media-asset-item"]').first(), + ).toBeVisible({ timeout: 15000 }); +} + +// Helper: select first asset and confirm +async function selectFirstAsset(page: Page) { + const firstAsset = page.locator('[data-testid="media-asset-item"]').first(); + await expect(firstAsset).toBeVisible({ timeout: 10000 }); + await firstAsset.click(); + // Click the Select button in the footer (targeted by testid to avoid ambiguity) + const selectBtn = page.locator('[data-testid="media-select-button"]'); + await expect(selectBtn).toBeVisible({ timeout: 3000 }); + await selectBtn.click(); + // Popover should close + await expect(page.getByText("Media Library")).not.toBeVisible({ + timeout: 5000, + }); +} + +/** + * Blog new-post has two "Browse Media" buttons: featured image (ImageInputField) + * and the markdown editor's picker (`data-testid="image-picker-trigger"`). + * Use this so the asset is inserted into the editor, not the featured field. + */ +async function openBlogEditorMediaPicker(page: Page) { + const trigger = page.locator( + '[data-testid="image-picker-trigger"] [data-testid="open-media-picker"]', + ); + await expect(trigger).toBeVisible({ timeout: 10000 }); + await trigger.evaluate((element) => + element.scrollIntoView({ block: "center", inline: "nearest" }), + ); + await trigger.click(); + await expect(page.getByText("Media Library")).toBeVisible({ timeout: 5000 }); +} + +test.describe("Media Plugin — direct upload via MediaPicker", () => { + test("MediaPicker trigger is visible on blog new post page", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); + }); + + await page.goto("/pages/blog/new", { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="new-post-page"]')).toBeVisible(); + + // ImageInputField renders a "Browse Media" button (open-media-picker) when no image is set + const trigger = page.locator('[data-testid="open-media-picker"]').first(); + await expect(trigger).toBeVisible({ timeout: 10000 }); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("MediaPicker trigger is visible on CMS product form", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); + }); + + await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); + + // ImageInputField renders a "Browse Media" button (open-media-picker) when no image is set + const trigger = page.locator('[data-testid="open-media-picker"]').first(); + await expect(trigger).toBeVisible({ timeout: 10000 }); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("can upload image via MediaPicker on CMS product form and save it", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); + }); + + const testRunId = Date.now().toString(36); + const productName = `Media Test ${testRunId}`; + + await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); + + // Fill required fields + await page.locator('input[name="name"]').fill(productName); + await page + .locator('textarea[name="description"]') + .fill("A product with media picker image"); + await page.locator('input[name="price"]').fill("49.99"); + + // Category select (required) + const categorySelect = page.locator('button[role="combobox"]').first(); + await categorySelect.click(); + await page.locator('[role="option"]').first().waitFor({ state: "visible" }); + await page.locator('[role="option"]').first().click(); + + // Open the MediaPicker from inside the file upload field + await openMediaPicker(page); + + // Upload an image via the Upload tab + await uploadInMediaPicker(page); + + // Select the uploaded asset + await selectFirstAsset(page); + + // After selection the image preview should appear + const imagePreview = page.locator('[data-testid="image-preview"]'); + await expect(imagePreview).toBeVisible({ timeout: 10000 }); + + // The preview src should be a real URL (from the local storage adapter), not a mock placeholder + const previewSrc = await imagePreview.getAttribute("src"); + expect(previewSrc).toBeTruthy(); + expect(previewSrc).not.toContain("placehold.co"); + + // Submit the form + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/\/pages\/cms\/product$/, { timeout: 15000 }); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("can upload image via MediaPicker Upload tab on blog new post form", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); + }); + + await page.goto("/pages/blog/new", { waitUntil: "networkidle" }); + await expect(page.locator('[data-testid="new-post-page"]')).toBeVisible(); + + // Wait for markdown editor to load + await page.waitForSelector(".milkdown-custom", { state: "visible" }); + await page.waitForTimeout(500); + + // Open the markdown editor's MediaPicker (not the featured-image picker above) + await openBlogEditorMediaPicker(page); + + // Upload a new image + await uploadInMediaPicker(page); + + // Select it — this inserts the image URL into the editor + await selectFirstAsset(page); + + // Crepe renders `![](url)` as under `.milkdown-custom`; the image + // node is not always a descendant of `[contenteditable]` (node views). + const editorImages = page.locator(".milkdown-custom img"); + await expect(editorImages.first()).toBeVisible({ timeout: 15000 }); + + // The image src should be a real URL (not a placeholder) + const imgSrc = await editorImages.first().getAttribute("src"); + expect(imgSrc).toBeTruthy(); + expect(imgSrc).not.toContain("placehold.co"); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("can select previously uploaded image from Browse tab", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); + }); + + // Navigate to CMS product form + await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); + + // First upload an image via the MediaPicker UI so it appears in the Browse tab later + await openMediaPicker(page); + await uploadInMediaPicker(page); + // Close the picker without selecting (click Cancel) + await page + .getByRole("button", { name: /cancel/i }) + .last() + .click(); + await expect(page.getByText("Media Library")).not.toBeVisible({ + timeout: 5000, + }); + + // Fill required fields + const testRunId = Date.now().toString(36); + await page.locator('input[name="name"]').fill(`Browse Test ${testRunId}`); + await page + .locator('textarea[name="description"]') + .fill("Testing browse tab"); + await page.locator('input[name="price"]').fill("9.99"); + + const categorySelect = page.locator('button[role="combobox"]').first(); + await categorySelect.click(); + await page.locator('[role="option"]').first().waitFor({ state: "visible" }); + await page.locator('[role="option"]').first().click(); + + // Reopen MediaPicker — the previously uploaded asset should appear in Browse tab + await openMediaPicker(page); + + // The previously uploaded asset should be visible in the Browse grid + const assetItem = page.locator('[data-testid="media-asset-item"]').first(); + await expect(assetItem).toBeVisible({ timeout: 10000 }); + + // Select it + await selectFirstAsset(page); + + // Preview should appear + const imagePreview = page.locator('[data-testid="image-preview"]'); + await expect(imagePreview).toBeVisible({ timeout: 10000 }); + + const previewSrc = await imagePreview.getAttribute("src"); + expect(previewSrc).toBeTruthy(); + expect(previewSrc).not.toContain("placehold.co"); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); + + test("can paste a URL via MediaPicker URL tab on CMS form", async ({ + page, + }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error" && isRealConsoleError(msg.text())) + errors.push(msg.text()); + }); + + const testUrl = "https://placehold.co/200/png"; + + await page.goto("/pages/cms/product/new", { waitUntil: "networkidle" }); + + // Open MediaPicker + await openMediaPicker(page); + + // Switch to URL tab + await page.getByRole("tab", { name: /url/i }).click(); + + // Fill in the URL input + const urlInput = page.locator('[data-testid="media-url-input"]'); + await expect(urlInput).toBeVisible({ timeout: 5000 }); + await urlInput.fill(testUrl); + + // Confirm + await page.getByRole("button", { name: /use url/i }).click(); + + // Popover closes and preview is shown + await expect(page.getByText("Media Library")).not.toBeVisible({ + timeout: 5000, + }); + const imagePreview = page.locator('[data-testid="image-preview"]'); + await expect(imagePreview).toBeVisible({ timeout: 5000 }); + // CMS uses `media.Image` (Next.js Image in the example app): the DOM `src` + // is often `/_next/image?url=...`, not the raw remote URL. + const previewSrc = await imagePreview.getAttribute("src"); + expect(previewSrc).toBeTruthy(); + expect( + previewSrc === testUrl || + previewSrc?.startsWith("/_next/image") || + previewSrc?.includes("placehold.co"), + ).toBe(true); + + expect(errors, `Console errors: \n${errors.join("\n")}`).toEqual([]); + }); +}); diff --git a/examples/nextjs/.gitignore b/examples/nextjs/.gitignore index 5ef6a520..73b1cc70 100644 --- a/examples/nextjs/.gitignore +++ b/examples/nextjs/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +/public/uploads/** # debug npm-debug.log* diff --git a/examples/nextjs/app/cms-example/page.tsx b/examples/nextjs/app/cms-example/page.tsx index 264a8379..ecba229a 100644 --- a/examples/nextjs/app/cms-example/page.tsx +++ b/examples/nextjs/app/cms-example/page.tsx @@ -13,10 +13,10 @@ import { getOrCreateQueryClient } from "@/lib/query-client" import type { CMSTypes } from "@/lib/cms-schemas" // Get base URL - works on both server and client -const getBaseURL = () => - typeof window !== 'undefined' - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") +const getBaseURL = () => + typeof window !== 'undefined' + ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:3000") // Mock file upload function async function mockUploadFile(file: File): Promise { @@ -29,7 +29,7 @@ async function mockUploadFile(file: File): Promise { // Shared Next.js Image wrapper function NextImageWrapper(props: React.ImgHTMLAttributes) { const { alt = "", src = "", width, height, ...rest } = props - + if (!width || !height) { return ( @@ -43,7 +43,7 @@ function NextImageWrapper(props: React.ImgHTMLAttributes) { ) } - + return ( {alt}("product", { limit: PAGE_SIZE }) if (typesLoading || itemsLoading) { @@ -110,9 +110,9 @@ function CMSExampleContent() { ) : (
{contentTypes.map((type) => ( -
{type.name} @@ -138,30 +138,45 @@ function CMSExampleContent() { <>
{items.map((item) => ( -
-
- {/* No more type guards needed - parsedData is fully typed! */} -

- {item.parsedData.name} -

-

- Slug: {item.slug} -

- {item.parsedData.description && ( -

- {item.parsedData.description} -

- )} - {item.parsedData.featured && ( - - Featured - +
+ {item.parsedData.image && ( +
+ {item.parsedData.name} +
)} + +
+ {/* No more type guards needed - parsedData is fully typed! */} +

+ {item.parsedData.name} +

+

+ Slug: {item.slug} +

+ {item.parsedData.description && ( +

+ {item.parsedData.description} +

+ )} + {item.parsedData.featured && ( + + Featured + + )} +
diff --git a/examples/nextjs/app/pages/layout.tsx b/examples/nextjs/app/pages/layout.tsx index c264a844..bab6ac57 100644 --- a/examples/nextjs/app/pages/layout.tsx +++ b/examples/nextjs/app/pages/layout.tsx @@ -18,35 +18,26 @@ import { defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" import { CommentThread } from "@btst/stack/plugins/comments/client/components" +import { uploadAsset, type MediaPluginOverrides } from "@btst/stack/plugins/media/client" +import { MediaPicker, ImageInputField } from "@btst/stack/plugins/media/client/components" import { resolveUser, searchUsers } from "@/lib/mock-users" +import { Button } from "@/components/ui/button" // Get base URL - works on both server and client // On server: uses process.env.BASE_URL // On client: uses NEXT_PUBLIC_BASE_URL or falls back to window.location.origin (which will be correct) -const getBaseURL = () => - typeof window !== 'undefined' - ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) - : (process.env.BASE_URL || "http://localhost:3000") +const getBaseURL = () => + typeof window !== 'undefined' + ? (process.env.NEXT_PUBLIC_BASE_URL || window.location.origin) + : (process.env.BASE_URL || "http://localhost:3000") -// Mock file upload URLs -const MOCK_IMAGE_URL = "https://placehold.co/400/png" -const MOCK_FILE_URL = "https://example-files.online-convert.com/document/txt/example.txt" -// Mock file upload function that returns appropriate URL based on file type -async function mockUploadFile(file: File): Promise { - console.log("uploadFile", file.name, file.type) - // Return image placeholder for images, txt file URL for other file types - if (file.type.startsWith("image/")) { - return MOCK_IMAGE_URL - } - return MOCK_FILE_URL -} // Shared Next.js Image wrapper for plugins // Handles both cases: with explicit dimensions or using fill mode function NextImageWrapper(props: React.ImgHTMLAttributes) { const { alt = "", src = "", width, height, ...rest } = props - + // Use fill mode if width or height are not provided if (!width || !height) { return ( @@ -61,7 +52,7 @@ function NextImageWrapper(props: React.ImgHTMLAttributes) { ) } - + return ( {alt} getOrCreateQueryClient()) const baseURL = getBaseURL() + const mediaClientConfig = React.useMemo( + () => ({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + uploadMode: "direct" as const, + }), + [baseURL], + ) + + const uploadImage = React.useCallback( + async (file: File) => { + const asset = await uploadAsset(mediaClientConfig, { file }) + return asset.url + }, + [mediaClientConfig], + ) + + // For chat file attachments we embed as a data URL so OpenAI can read the + // content directly — a local /uploads/... path is not reachable from OpenAI's servers. + const uploadFileForChat = React.useCallback( + (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => resolve(e.target?.result as string) + reader.onerror = () => reject(new Error("Failed to read file")) + reader.readAsDataURL(file) + }), + [], + ); return ( @@ -112,7 +133,9 @@ export default function ExampleLayout({ apiBasePath: "/api/data", navigate: (path) => router.push(path), refresh: () => router.refresh(), - uploadImage: mockUploadFile, + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, Image: NextImageWrapper, // Wire comments into the bottom of each blog post postBottomSlot: (post) => ( @@ -129,29 +152,29 @@ export default function ExampleLayout({ ), // Lifecycle Hooks - called during route rendering onRouteRender: async (routeName, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onRouteRender: Route rendered:`, routeName, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onRouteRender: Route rendered:`, routeName, context.path); }, onRouteError: async (routeName, error, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onRouteError: Route error:`, routeName, error.message, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onRouteError: Route error:`, routeName, error.message, context.path); }, onBeforePostsPageRendered: (context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostsPageRendered: checking access for`, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforePostsPageRendered: checking access for`, context.path); return true; }, onBeforeDraftsPageRendered: (context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeDraftsPageRendered: checking auth for`, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeDraftsPageRendered: checking auth for`, context.path); return true; }, onBeforeNewPostPageRendered: (context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeNewPostPageRendered: checking permissions for`, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeNewPostPageRendered: checking permissions for`, context.path); return true; }, onBeforeEditPostPageRendered: (slug, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeEditPostPageRendered: checking permissions for`, slug, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeEditPostPageRendered: checking permissions for`, slug, context.path); return true; }, onBeforePostPageRendered: (slug, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforePostPageRendered: checking access for`, slug, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforePostPageRendered: checking access for`, slug, context.path); return true; }, }, @@ -161,7 +184,7 @@ export default function ExampleLayout({ apiBasePath: "/api/data", navigate: (path) => router.push(path), refresh: () => router.refresh(), - uploadFile: mockUploadFile, + uploadFile: uploadFileForChat, Link: ({ href, ...props }) => , Image: NextImageWrapper, chatSuggestions: [ @@ -173,10 +196,10 @@ export default function ExampleLayout({ ], // Lifecycle hooks onRouteRender: async (routeName, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] AI Chat route:`, routeName, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] AI Chat route:`, routeName, context.path); }, onRouteError: async (routeName, error, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] AI Chat error:`, routeName, error.message); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] AI Chat error:`, routeName, error.message); }, }, cms: { @@ -184,71 +207,17 @@ export default function ExampleLayout({ apiBasePath: "/api/data", navigate: (path) => router.push(path), refresh: () => router.refresh(), - uploadImage: mockUploadFile, + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, Link: ({ href, ...props }) => , Image: NextImageWrapper, - // Custom field components for CMS forms - // These override the default auto-form field types - fieldComponents: { - // Override "file" to use uploadImage from context - file: ({ field, label, isRequired, fieldConfigItem, fieldProps }) => { - const [preview, setPreview] = React.useState(field.value || null); - const [uploading, setUploading] = React.useState(false); - // Sync preview with field.value when it changes (e.g., when editing an existing item) - React.useEffect(() => { - const normalizedValue = field.value || null; - if (normalizedValue !== preview) { - setPreview(normalizedValue); - } - }, [field.value, preview]); - return ( -
- - {!preview ? ( - { - const file = e.target.files?.[0]; - if (file) { - setUploading(true); - try { - const url = await mockUploadFile(file); - setPreview(url); - field.onChange(url); - } finally { - setUploading(false); - } - } - }} - className="block w-full text-sm" - /> - ) : ( -
- Preview - -
- )} - {fieldConfigItem?.description && ( -

{String(fieldConfigItem.description)}

- )} -
- ); - }, - }, // Lifecycle hooks onRouteRender: async (routeName, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] CMS route:`, routeName, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] CMS route:`, routeName, context.path); }, onRouteError: async (routeName, error, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] CMS error:`, routeName, error.message); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] CMS error:`, routeName, error.message); }, }, "form-builder": { @@ -259,10 +228,10 @@ export default function ExampleLayout({ Link: ({ href, ...props }) => , // Lifecycle hooks onRouteRender: async (routeName, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Form Builder route:`, routeName, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] Form Builder route:`, routeName, context.path); }, onRouteError: async (routeName, error, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Form Builder error:`, routeName, error.message); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] Form Builder error:`, routeName, error.message); }, }, "ui-builder": { @@ -279,6 +248,9 @@ export default function ExampleLayout({ navigate: (path) => router.push(path), refresh: () => router.refresh(), Link: ({ href, ...props }) => , + + uploadImage, + imagePicker: ImagePicker, // User resolution for assignees resolveUser, searchUsers, @@ -296,17 +268,17 @@ export default function ExampleLayout({ ), // Lifecycle hooks onRouteRender: async (routeName, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban route:`, routeName, context.path); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] Kanban route:`, routeName, context.path); }, onRouteError: async (routeName, error, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] Kanban error:`, routeName, error.message); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] Kanban error:`, routeName, error.message); }, onBeforeBoardsPageRendered: (context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeBoardsPageRendered`); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeBoardsPageRendered`); return true; }, onBeforeBoardPageRendered: (boardId, context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeBoardPageRendered:`, boardId); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeBoardPageRendered:`, boardId); return true; }, }, @@ -317,17 +289,24 @@ export default function ExampleLayout({ currentUserId: "olliethedev", defaultCommentPageSize: 5, resourceLinks: { - "blog-post": (slug) => `/pages/blog/${slug}`, + "blog-post": (slug) => `/pages/blog/${ slug }`, }, onBeforeModerationPageRendered: (context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeModerationPageRendered`); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeModerationPageRendered`); return true; // In production: check admin role }, onBeforeUserCommentsPageRendered: (context) => { - console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); + console.log(`[${ context.isSSR ? 'SSR' : 'CSR' }] onBeforeUserCommentsPageRendered`); return true; // In production: check authenticated session }, - } + }, + media: { + ...mediaClientConfig, + queryClient, + navigate: (path) => router.push(path), + Link: ({ href, ...props }) => , + Image: NextImageWrapper, + }, }} > {children} @@ -349,3 +328,20 @@ export default function ExampleLayout({ ) } +const ImagePicker = ({ onSelect }: { onSelect: (url: string) => void }) => { + return ( + + Browse Media + + } + accept={["image/*"]} + onSelect={(assets) => onSelect(assets[0]?.url ?? "")} + /> + ) +} diff --git a/examples/nextjs/lib/stack-client.tsx b/examples/nextjs/lib/stack-client.tsx index 5dcccd8c..6db093a8 100644 --- a/examples/nextjs/lib/stack-client.tsx +++ b/examples/nextjs/lib/stack-client.tsx @@ -8,6 +8,7 @@ import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plu import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" +import { mediaClientPlugin } from "@btst/stack/plugins/media/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -185,6 +186,15 @@ export const getStackClient = ( queryClient: queryClient, headers: options?.headers, }), + // Media plugin — registers the /media library route + media: mediaClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + headers: options?.headers, + }), } }) } diff --git a/examples/nextjs/lib/stack.ts b/examples/nextjs/lib/stack.ts index 21047ea7..c673be29 100644 --- a/examples/nextjs/lib/stack.ts +++ b/examples/nextjs/lib/stack.ts @@ -9,6 +9,7 @@ import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" +import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" import { tool } from "ai" @@ -440,6 +441,12 @@ Keep all responses concise. Do not discuss the technology stack or internal tool revalidatePath("/pages/ssg-kanban", "page"); }, }), + // Media plugin for asset management + media: mediaBackendPlugin({ + storageAdapter: localAdapter(), + // Allow external URLs for testing (e.g. placehold.co used by e2e smoke tests) + allowedUrlPrefixes: ["https://placehold.co"], + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/react-router/.gitignore b/examples/react-router/.gitignore index 039ee62d..afa2f3bf 100644 --- a/examples/react-router/.gitignore +++ b/examples/react-router/.gitignore @@ -5,3 +5,6 @@ # React Router /.react-router/ /build/ + +#misc +/public/uploads/** diff --git a/examples/react-router/app/lib/stack-client.tsx b/examples/react-router/app/lib/stack-client.tsx index 12ede79c..2aa9647f 100644 --- a/examples/react-router/app/lib/stack-client.tsx +++ b/examples/react-router/app/lib/stack-client.tsx @@ -7,6 +7,7 @@ import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plugins/ui-builder/client" import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" +import { mediaClientPlugin } from "@btst/stack/plugins/media/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -142,6 +143,14 @@ export const getStackClient = (queryClient: QueryClient) => { siteBasePath: "/pages", queryClient: queryClient, }), + // Media plugin — registers the /media library route + media: mediaClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/react-router/app/lib/stack.ts b/examples/react-router/app/lib/stack.ts index 17965fb6..441882a9 100644 --- a/examples/react-router/app/lib/stack.ts +++ b/examples/react-router/app/lib/stack.ts @@ -7,6 +7,7 @@ import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" +import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" @@ -210,6 +211,10 @@ const { handler, dbSchema } = stack({ return ctx?.headers?.get?.("x-user-id") ?? null }, }), + // Media plugin for asset management + media: mediaBackendPlugin({ + storageAdapter: localAdapter(), + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/react-router/app/routes/pages/_layout.tsx b/examples/react-router/app/routes/pages/_layout.tsx index dc7ccb6a..fb826c63 100644 --- a/examples/react-router/app/routes/pages/_layout.tsx +++ b/examples/react-router/app/routes/pages/_layout.tsx @@ -1,5 +1,5 @@ // app/routes/__root.tsx -import { useState, useEffect } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Outlet, Link, useNavigate } from "react-router"; import { StackProvider } from "@btst/stack/context" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" @@ -10,7 +10,11 @@ import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builde import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" import { CommentThread } from "@btst/stack/plugins/comments/client/components" +import { uploadAsset, type MediaPluginOverrides } from "@btst/stack/plugins/media/client" +import { MediaPicker, ImageInputField } from "@btst/stack/plugins/media/client/components" +import { Button } from "../../components/ui/button" import { resolveUser, searchUsers } from "../../lib/mock-users" +import { getOrCreateQueryClient } from "../../lib/query-client" // Get base URL function - works on both server and client // On server: uses process.env.BASE_URL @@ -20,19 +24,6 @@ const getBaseURL = () => ? (import.meta.env.VITE_BASE_URL || window.location.origin) : (process.env.BASE_URL || "http://localhost:5173") -// Mock file upload URLs -const MOCK_IMAGE_URL = "https://placehold.co/400/png" -const MOCK_FILE_URL = "https://example-files.online-convert.com/document/txt/example.txt" - -// Mock file upload function that returns appropriate URL based on file type -async function mockUploadFile(file: File): Promise { - console.log("uploadFile", file.name, file.type) - // Return image placeholder for images, txt file URL for other file types - if (file.type.startsWith("image/")) { - return MOCK_IMAGE_URL - } - return MOCK_FILE_URL -} // Define the shape of all plugin overrides type PluginOverrides = { @@ -42,6 +33,7 @@ async function mockUploadFile(file: File): Promise { "form-builder": FormBuilderPluginOverrides, kanban: KanbanPluginOverrides, comments: CommentsPluginOverrides, + media: MediaPluginOverrides, } export default function Layout() { @@ -49,6 +41,34 @@ export default function Layout() { const baseURL = getBaseURL() console.log("baseURL", baseURL) const navigate = useNavigate() + const [queryClient] = useState(() => getOrCreateQueryClient()) + const mediaClientConfig = useMemo( + () => ({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + uploadMode: "direct" as const, + }), + [baseURL], + ) + + const uploadImage = useCallback(async (file: File) => { + const asset = await uploadAsset(mediaClientConfig, { file }) + return asset.url + }, [mediaClientConfig]) + + // For chat file attachments we embed as a data URL so OpenAI can read the + // content directly — a local /uploads/... path is not reachable from OpenAI's servers. + const uploadFileForChat = useCallback( + (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => resolve(e.target?.result as string) + reader.onerror = () => reject(new Error("Failed to read file")) + reader.readAsDataURL(file) + }), + [], + ) + return ( @@ -58,7 +78,9 @@ export default function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => navigate(href), - uploadImage: mockUploadFile, + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, Link: ({ href, children, className, ...props }) => ( {children} @@ -110,7 +132,7 @@ export default function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => navigate(href), - uploadFile: mockUploadFile, + uploadFile: uploadFileForChat, Link: ({ href, children, className, ...props }) => ( {children} @@ -124,68 +146,14 @@ export default function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => navigate(href), - uploadImage: mockUploadFile, + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, Link: ({ href, children, className, ...props }) => ( {children} ), - // Custom field components for CMS forms - // These override the default auto-form field types - fieldComponents: { - // Override "file" to use uploadImage from context - file: ({ field, label, isRequired, fieldConfigItem }) => { - const [preview, setPreview] = useState(field.value || null); - const [uploading, setUploading] = useState(false); - // Sync preview with field.value when it changes (e.g., when editing an existing item) - useEffect(() => { - const normalizedValue = field.value || null; - if (normalizedValue !== preview) { - setPreview(normalizedValue); - } - }, [field.value, preview]); - return ( -
- - {!preview ? ( - { - const file = e.target.files?.[0]; - if (file) { - setUploading(true); - try { - const url = await mockUploadFile(file); - setPreview(url); - field.onChange(url); - } finally { - setUploading(false); - } - } - }} - className="block w-full text-sm" - /> - ) : ( -
- Preview - -
- )} - {fieldConfigItem?.description && ( -

{String(fieldConfigItem.description)}

- )} -
- ); - }, - }, onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] CMS route:`, routeName, context.path); }, @@ -215,6 +183,8 @@ export default function Layout() { {children} ), + uploadImage, + imagePicker: ImagePicker, // User resolution for assignees resolveUser, searchUsers, @@ -252,7 +222,17 @@ export default function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); return true; // In production: check authenticated session }, - } + }, + media: { + ...mediaClientConfig, + queryClient, + navigate: (href) => navigate(href), + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + }, }} > @@ -273,3 +253,21 @@ export default function Layout() { ); } + +const ImagePicker = ({ onSelect }: { onSelect: (url: string) => void }) => { + return ( + + Browse Media + + } + accept={["image/*"]} + onSelect={(assets) => onSelect(assets[0]?.url ?? "")} + /> + ) +} diff --git a/examples/tanstack/.gitignore b/examples/tanstack/.gitignore index d530986d..58e7b63d 100644 --- a/examples/tanstack/.gitignore +++ b/examples/tanstack/.gitignore @@ -1,3 +1,6 @@ .nitro .output -.tanstack \ No newline at end of file +.tanstack + +# Local media uploads (localAdapter writes to public/uploads/) +/public/uploads/** \ No newline at end of file diff --git a/examples/tanstack/src/lib/stack-client.tsx b/examples/tanstack/src/lib/stack-client.tsx index 043ce077..1af362f5 100644 --- a/examples/tanstack/src/lib/stack-client.tsx +++ b/examples/tanstack/src/lib/stack-client.tsx @@ -7,6 +7,7 @@ import { uiBuilderClientPlugin, defaultComponentRegistry } from "@btst/stack/plu import { routeDocsClientPlugin } from "@btst/stack/plugins/route-docs/client" import { kanbanClientPlugin } from "@btst/stack/plugins/kanban/client" import { commentsClientPlugin } from "@btst/stack/plugins/comments/client" +import { mediaClientPlugin } from "@btst/stack/plugins/media/client" import { QueryClient } from "@tanstack/react-query" // Get base URL function - works on both server and client @@ -142,6 +143,14 @@ export const getStackClient = (queryClient: QueryClient) => { siteBasePath: "/pages", queryClient: queryClient, }), + // Media plugin — registers the /media library route + media: mediaClientPlugin({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + siteBaseURL: baseURL, + siteBasePath: "/pages", + queryClient: queryClient, + }), } }) } diff --git a/examples/tanstack/src/lib/stack.ts b/examples/tanstack/src/lib/stack.ts index a0d84b65..e0d2cf44 100644 --- a/examples/tanstack/src/lib/stack.ts +++ b/examples/tanstack/src/lib/stack.ts @@ -7,6 +7,7 @@ import { formBuilderBackendPlugin } from "@btst/stack/plugins/form-builder/api" import { openApiBackendPlugin } from "@btst/stack/plugins/open-api/api" import { kanbanBackendPlugin } from "@btst/stack/plugins/kanban/api" import { commentsBackendPlugin } from "@btst/stack/plugins/comments/api" +import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api" import { UI_BUILDER_CONTENT_TYPE } from "@btst/stack/plugins/ui-builder" import { openai } from "@ai-sdk/openai" @@ -209,6 +210,10 @@ const { handler, dbSchema } = stack({ return ctx?.headers?.get?.("x-user-id") ?? null }, }), + // Media plugin for asset management + media: mediaBackendPlugin({ + storageAdapter: localAdapter(), + }), }, adapter: (db) => createMemoryAdapter(db)({}) }) diff --git a/examples/tanstack/src/routes/pages/route.tsx b/examples/tanstack/src/routes/pages/route.tsx index f45412f5..d715bb7a 100644 --- a/examples/tanstack/src/routes/pages/route.tsx +++ b/examples/tanstack/src/routes/pages/route.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from "react" import { StackProvider } from "@btst/stack/context" import { QueryClientProvider } from "@tanstack/react-query" import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { useCallback, useMemo } from "react" import type { BlogPluginOverrides } from "@btst/stack/plugins/blog/client" import type { AiChatPluginOverrides } from "@btst/stack/plugins/ai-chat/client" import { ChatLayout } from "@btst/stack/plugins/ai-chat/client" @@ -10,6 +10,9 @@ import type { FormBuilderPluginOverrides } from "@btst/stack/plugins/form-builde import type { KanbanPluginOverrides } from "@btst/stack/plugins/kanban/client" import type { CommentsPluginOverrides } from "@btst/stack/plugins/comments/client" import { CommentThread } from "@btst/stack/plugins/comments/client/components" +import { uploadAsset, type MediaPluginOverrides } from "@btst/stack/plugins/media/client" +import { MediaPicker, ImageInputField } from "@btst/stack/plugins/media/client/components" +import { Button } from "../../components/ui/button" import { resolveUser, searchUsers } from "../../lib/mock-users" import { Link, useRouter, Outlet, createFileRoute } from "@tanstack/react-router" @@ -21,20 +24,6 @@ const getBaseURL = () => ? (import.meta.env.VITE_BASE_URL || window.location.origin) : (process.env.BASE_URL || "http://localhost:3000") -// Mock file upload URLs -const MOCK_IMAGE_URL = "https://placehold.co/400/png" -const MOCK_FILE_URL = "https://example-files.online-convert.com/document/txt/example.txt" - -// Mock file upload function that returns appropriate URL based on file type -async function mockUploadFile(file: File): Promise { - console.log("uploadFile", file.name, file.type) - // Return image placeholder for images, txt file URL for other file types - if (file.type.startsWith("image/")) { - return MOCK_IMAGE_URL - } - return MOCK_FILE_URL -} - // Define the shape of all plugin overrides type PluginOverrides = { blog: BlogPluginOverrides, @@ -43,6 +32,7 @@ type PluginOverrides = { "form-builder": FormBuilderPluginOverrides, kanban: KanbanPluginOverrides, comments: CommentsPluginOverrides, + media: MediaPluginOverrides, } export const Route = createFileRoute('/pages')({ @@ -54,11 +44,37 @@ export const Route = createFileRoute('/pages')({ function Layout() { const router = useRouter() - const context = Route.useRouteContext() + const routeContext = Route.useRouteContext() const baseURL = getBaseURL() + const mediaClientConfig = useMemo( + () => ({ + apiBaseURL: baseURL, + apiBasePath: "/api/data", + uploadMode: "direct" as const, + }), + [baseURL], + ) + + const uploadImage = useCallback(async (file: File) => { + const asset = await uploadAsset(mediaClientConfig, { file }) + return asset.url + }, [mediaClientConfig]) + + // For chat file attachments we embed as a data URL so OpenAI can read the + // content directly — a local /uploads/... path is not reachable from OpenAI's servers. + const uploadFileForChat = useCallback( + (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => resolve(e.target?.result as string) + reader.onerror = () => reject(new Error("Failed to read file")) + reader.readAsDataURL(file) + }), + [], + ) return ( - + basePath="/pages" @@ -67,7 +83,9 @@ function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => router.navigate({ href }), - uploadImage: mockUploadFile, + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, Link: ({ href, children, className, ...props }) => ( {children} @@ -119,7 +137,7 @@ function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => router.navigate({ href }), - uploadFile: mockUploadFile, + uploadFile: uploadFileForChat, Link: ({ href, children, className, ...props }) => ( {children} @@ -133,68 +151,14 @@ function Layout() { apiBaseURL: baseURL, apiBasePath: "/api/data", navigate: (href) => router.navigate({ href }), - uploadImage: mockUploadFile, + uploadImage, + imagePicker: ImagePicker, + imageInputField: ImageInputField, Link: ({ href, children, className, ...props }) => ( {children} ), - // Custom field components for CMS forms - // These override the default auto-form field types - fieldComponents: { - // Override "file" to use uploadImage from context - file: ({ field, label, isRequired, fieldConfigItem }) => { - const [preview, setPreview] = useState(field.value || null); - const [uploading, setUploading] = useState(false); - // Sync preview with field.value when it changes (e.g., when editing an existing item) - useEffect(() => { - const normalizedValue = field.value || null; - if (normalizedValue !== preview) { - setPreview(normalizedValue); - } - }, [field.value, preview]); - return ( -
- - {!preview ? ( - { - const file = e.target.files?.[0]; - if (file) { - setUploading(true); - try { - const url = await mockUploadFile(file); - setPreview(url); - field.onChange(url); - } finally { - setUploading(false); - } - } - }} - className="block w-full text-sm" - /> - ) : ( -
- Preview - -
- )} - {fieldConfigItem?.description && ( -

{String(fieldConfigItem.description)}

- )} -
- ); - }, - }, onRouteRender: async (routeName, context) => { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] CMS route:`, routeName, context.path); }, @@ -224,6 +188,8 @@ function Layout() { {children} ), + uploadImage, + imagePicker: ImagePicker, // User resolution for assignees resolveUser, searchUsers, @@ -261,7 +227,17 @@ function Layout() { console.log(`[${context.isSSR ? 'SSR' : 'CSR'}] onBeforeUserCommentsPageRendered`); return true; // In production: check authenticated session }, - } + }, + media: { + ...mediaClientConfig, + queryClient: routeContext.queryClient, + navigate: (href) => router.navigate({ href }), + Link: ({ href, children, className, ...props }) => ( + + {children} + + ), + }, }} > @@ -283,3 +259,20 @@ function Layout() { ) } +const ImagePicker = ({ onSelect }: { onSelect: (url: string) => void }) => { + return ( + + Browse Media + + } + accept={["image/*"]} + onSelect={(assets) => onSelect(assets[0]?.url ?? "")} + /> + ) +} diff --git a/packages/stack/build.config.ts b/packages/stack/build.config.ts index 1dd43178..86fce7e8 100644 --- a/packages/stack/build.config.ts +++ b/packages/stack/build.config.ts @@ -117,6 +117,12 @@ export default defineBuildConfig({ "./src/plugins/comments/query-keys.ts", // media plugin entries "./src/plugins/media/api/index.ts", + "./src/plugins/media/api/adapters/s3.ts", + "./src/plugins/media/api/adapters/vercel-blob.ts", + "./src/plugins/media/client/index.ts", + "./src/plugins/media/client/components/index.tsx", + "./src/plugins/media/client/hooks/index.tsx", + "./src/plugins/media/query-keys.ts", "./src/components/auto-form/index.ts", "./src/components/stepped-auto-form/index.ts", "./src/components/multi-select/index.ts", diff --git a/packages/stack/knip.json b/packages/stack/knip.json index 64e1f770..c31759d8 100644 --- a/packages/stack/knip.json +++ b/packages/stack/knip.json @@ -40,7 +40,16 @@ "src/plugins/kanban/client/components/index.tsx", "src/plugins/kanban/client/hooks/index.tsx", "src/plugins/kanban/query-keys.ts", + "src/plugins/comments/api/index.ts", + "src/plugins/comments/client/index.ts", + "src/plugins/comments/client/components/index.tsx", + "src/plugins/comments/client/hooks/index.tsx", + "src/plugins/comments/query-keys.ts", "src/plugins/media/api/index.ts", + "src/plugins/media/client/index.ts", + "src/plugins/media/client/components/index.tsx", + "src/plugins/media/client/hooks/index.tsx", + "src/plugins/media/query-keys.ts", "build.config.ts", "vitest.config.mts", "scripts/build-registry.ts", diff --git a/packages/stack/package.json b/packages/stack/package.json index cb0ba9c0..b0d6ac8f 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "2.8.1", + "version": "2.9.1", "description": "A composable, plugin-based library for building full-stack applications.", "repository": { "type": "git", @@ -424,6 +424,67 @@ "default": "./dist/plugins/media/api/index.cjs" } }, + "./plugins/media/api/adapters/s3": { + "import": { + "types": "./dist/plugins/media/api/adapters/s3.d.ts", + "default": "./dist/plugins/media/api/adapters/s3.mjs" + }, + "require": { + "types": "./dist/plugins/media/api/adapters/s3.d.cts", + "default": "./dist/plugins/media/api/adapters/s3.cjs" + } + }, + "./plugins/media/api/adapters/vercel-blob": { + "import": { + "types": "./dist/plugins/media/api/adapters/vercel-blob.d.ts", + "default": "./dist/plugins/media/api/adapters/vercel-blob.mjs" + }, + "require": { + "types": "./dist/plugins/media/api/adapters/vercel-blob.d.cts", + "default": "./dist/plugins/media/api/adapters/vercel-blob.cjs" + } + }, + "./plugins/media/client": { + "import": { + "types": "./dist/plugins/media/client/index.d.ts", + "default": "./dist/plugins/media/client/index.mjs" + }, + "require": { + "types": "./dist/plugins/media/client/index.d.cts", + "default": "./dist/plugins/media/client/index.cjs" + } + }, + "./plugins/media/client/components": { + "import": { + "types": "./dist/plugins/media/client/components/index.d.ts", + "default": "./dist/plugins/media/client/components/index.mjs" + }, + "require": { + "types": "./dist/plugins/media/client/components/index.d.cts", + "default": "./dist/plugins/media/client/components/index.cjs" + } + }, + "./plugins/media/client/hooks": { + "import": { + "types": "./dist/plugins/media/client/hooks/index.d.ts", + "default": "./dist/plugins/media/client/hooks/index.mjs" + }, + "require": { + "types": "./dist/plugins/media/client/hooks/index.d.cts", + "default": "./dist/plugins/media/client/hooks/index.cjs" + } + }, + "./plugins/media/query-keys": { + "import": { + "types": "./dist/plugins/media/query-keys.d.ts", + "default": "./dist/plugins/media/query-keys.mjs" + }, + "require": { + "types": "./dist/plugins/media/query-keys.d.cts", + "default": "./dist/plugins/media/query-keys.cjs" + } + }, + "./plugins/media/css": "./dist/plugins/media/client.css", "./plugins/route-docs/client": { "import": { "types": "./dist/plugins/route-docs/client/index.d.ts", @@ -623,6 +684,24 @@ "plugins/media/api": [ "./dist/plugins/media/api/index.d.ts" ], + "plugins/media/api/adapters/s3": [ + "./dist/plugins/media/api/adapters/s3.d.ts" + ], + "plugins/media/api/adapters/vercel-blob": [ + "./dist/plugins/media/api/adapters/vercel-blob.d.ts" + ], + "plugins/media/client": [ + "./dist/plugins/media/client/index.d.ts" + ], + "plugins/media/client/components": [ + "./dist/plugins/media/client/components/index.d.ts" + ], + "plugins/media/client/hooks": [ + "./dist/plugins/media/client/hooks/index.d.ts" + ], + "plugins/media/query-keys": [ + "./dist/plugins/media/query-keys.d.ts" + ], "plugins/route-docs/client": [ "./dist/plugins/route-docs/client/index.d.ts" ], diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json index f3f6ca66..e830291b 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -58,7 +58,7 @@ { "path": "btst/blog/client/components/forms/image-field.tsx", "type": "registry:component", - "content": "import { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormDescription,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { Loader2, Upload } from \"lucide-react\";\nimport { useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\nexport function FeaturedImageField({\n\tisRequired,\n\tvalue,\n\tonChange,\n\tsetFeaturedImageUploading,\n}: {\n\tisRequired?: boolean;\n\tvalue?: string;\n\tonChange: (value: string) => void;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n}) {\n\tconst fileInputRef = useRef(null);\n\tconst [isUploading, setIsUploading] = useState(false);\n\n\tconst { uploadImage, Image, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", { localization: BLOG_LOCALIZATION });\n\n\tconst ImageComponent = Image ? Image : DefaultImage;\n\n\tconst handleImageUpload = async (\n\t\tevent: React.ChangeEvent,\n\t) => {\n\t\tconst file = event.target.files?.[0];\n\t\tif (!file) return;\n\n\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE);\n\t\t\treturn;\n\t\t}\n\n\t\tif (file.size > 4 * 1024 * 1024) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tsetIsUploading(true);\n\t\t\tsetFeaturedImageUploading(true);\n\t\t\tconst url = await uploadImage(file);\n\t\t\tonChange(url);\n\t\t\ttoast.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS);\n\t\t} catch (error) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE);\n\t\t\tconsole.error(\"Failed to upload image:\", error);\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE);\n\t\t} finally {\n\t\t\tsetIsUploading(false);\n\t\t\tsetFeaturedImageUploading(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_LABEL}\n\t\t\t\t{isRequired && (\n\t\t\t\t\t\n\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t onChange(e.target.value)}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\t{value && !isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultImage({\n\tsrc,\n\talt,\n\tclassName,\n\twidth,\n\theight,\n}: {\n\tsrc: string;\n\talt: string;\n\tclassName?: string;\n\twidth?: number;\n\theight?: number;\n}) {\n\treturn (\n\t\t\n\t);\n}\n", + "content": "import { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormDescription,\n\tFormItem,\n\tFormLabel,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { Loader2, Upload } from \"lucide-react\";\nimport { useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\n\nexport function FeaturedImageField({\n\tisRequired,\n\tvalue,\n\tonChange,\n\tsetFeaturedImageUploading,\n}: {\n\tisRequired?: boolean;\n\tvalue?: string;\n\tonChange: (value: string) => void;\n\tsetFeaturedImageUploading: (uploading: boolean) => void;\n}) {\n\tconst fileInputRef = useRef(null);\n\tconst [isUploading, setIsUploading] = useState(false);\n\n\tconst {\n\t\tuploadImage,\n\t\tImage,\n\t\tlocalization,\n\t\timageInputField: ImageInput,\n\t} = usePluginOverrides>(\n\t\t\"blog\",\n\t\t{ localization: BLOG_LOCALIZATION },\n\t);\n\n\tconst ImageComponent = Image ? Image : DefaultImage;\n\n\t// When a custom imageInput component is provided via overrides, delegate to it.\n\tif (ImageInput) {\n\t\treturn (\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_LABEL}\n\t\t\t\t\t{isRequired && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\tconst handleImageUpload = async (\n\t\tevent: React.ChangeEvent,\n\t) => {\n\t\tconst file = event.target.files?.[0];\n\t\tif (!file) return;\n\n\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE);\n\t\t\treturn;\n\t\t}\n\n\t\tif (file.size > 4 * 1024 * 1024) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE);\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tsetIsUploading(true);\n\t\t\tsetFeaturedImageUploading(true);\n\t\t\tconst url = await uploadImage(file);\n\t\t\tonChange(url);\n\t\t\ttoast.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS);\n\t\t} catch (error) {\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE);\n\t\t\tconsole.error(\"Failed to upload image:\", error);\n\t\t\ttoast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE);\n\t\t} finally {\n\t\t\tsetIsUploading(false);\n\t\t\tsetFeaturedImageUploading(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_LABEL}\n\t\t\t\t{isRequired && (\n\t\t\t\t\t\n\t\t\t\t\t\t{\" \"}\n\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t onChange(e.target.value)}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT}\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t\t{value && !isUploading && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t
\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction DefaultImage({\n\tsrc,\n\talt,\n\tclassName,\n\twidth,\n\theight,\n}: {\n\tsrc: string;\n\talt: string;\n\tclassName?: string;\n\twidth?: number;\n\theight?: number;\n}) {\n\treturn (\n\t\t\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/image-field.tsx" }, { @@ -70,13 +70,13 @@ { "path": "btst/blog/client/components/forms/markdown-editor-with-overrides.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t\"uploadImage\" | \"placeholder\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst { uploadImage, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\treturn (\n\t\t\n\t);\n}\n", + "content": "\"use client\";\nimport { useCallback, useRef } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t| \"uploadImage\"\n\t| \"placeholder\"\n\t| \"insertImageRef\"\n\t| \"openMediaPickerForImageBlock\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst {\n\t\tuploadImage,\n\t\timagePicker: ImagePickerTrigger,\n\t\tlocalization,\n\t} = usePluginOverrides>(\n\t\t\"blog\",\n\t\t{ localization: BLOG_LOCALIZATION },\n\t);\n\n\tconst insertImageRef = useRef<((url: string) => void) | null>(null);\n\t// Holds the Crepe-image-block `setUrl` callback while the picker is open.\n\tconst pendingInsertUrlRef = useRef<((url: string) => void) | null>(null);\n\t// Ref to the trigger wrapper so we can programmatically click the picker button.\n\tconst triggerContainerRef = useRef(null);\n\n\t// Single onSelect handler for ImagePickerTrigger.\n\t// URLs returned by the media plugin are already percent-encoded at the\n\t// source (storage adapter), so no additional encoding is applied here.\n\tconst handleSelect = useCallback((url: string) => {\n\t\tif (pendingInsertUrlRef.current) {\n\t\t\t// Crepe image block flow: set the URL into the block's link input.\n\t\t\tpendingInsertUrlRef.current(url);\n\t\t\tpendingInsertUrlRef.current = null;\n\t\t} else {\n\t\t\t// Normal flow: insert image at end of markdown content.\n\t\t\tinsertImageRef.current?.(url);\n\t\t}\n\t}, []);\n\n\t// Called by MarkdownEditor's click interceptor when the user clicks a Crepe\n\t// image-block upload placeholder.\n\tconst openMediaPickerForImageBlock = useCallback(\n\t\t(setUrl: (url: string) => void) => {\n\t\t\tpendingInsertUrlRef.current = setUrl;\n\t\t\t// Programmatically click the visible picker trigger button.\n\t\t\tconst btn = triggerContainerRef.current?.querySelector(\n\t\t\t\t'[data-testid=\"open-media-picker\"]',\n\t\t\t) as HTMLButtonElement | null;\n\t\t\tbtn?.click();\n\t\t},\n\t\t[],\n\t);\n\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t{ImagePickerTrigger && (\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/markdown-editor-with-overrides.tsx" }, { "path": "btst/blog/client/components/forms/markdown-editor.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { Crepe, CrepeFeature } from \"@milkdown/crepe\";\nimport \"@milkdown/crepe/theme/common/style.css\";\nimport \"./markdown-editor-styles.css\";\n\nimport { cn, throttle } from \"../../../utils\";\nimport { editorViewCtx, parserCtx } from \"@milkdown/kit/core\";\nimport { listener, listenerCtx } from \"@milkdown/kit/plugin/listener\";\nimport { Slice } from \"@milkdown/kit/prose/model\";\nimport { Selection } from \"@milkdown/kit/prose/state\";\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\nexport interface MarkdownEditorProps {\n\tvalue?: string;\n\tonChange?: (markdown: string) => void;\n\tclassName?: string;\n\t/** Optional image upload handler. When provided, enables image upload in the editor. */\n\tuploadImage?: (file: File) => Promise;\n\t/** Placeholder text shown when the editor is empty. */\n\tplaceholder?: string;\n}\n\nexport function MarkdownEditor({\n\tvalue,\n\tonChange,\n\tclassName,\n\tuploadImage,\n\tplaceholder = \"Write something...\",\n}: MarkdownEditorProps) {\n\tconst containerRef = useRef(null);\n\tconst crepeRef = useRef(null);\n\tconst isReadyRef = useRef(false);\n\tconst [isReady, setIsReady] = useState(false);\n\tconst onChangeRef = useRef(onChange);\n\tconst initialValueRef = useRef(value ?? \"\");\n\ttype ThrottledFn = ((markdown: string) => void) & {\n\t\tcancel?: () => void;\n\t\tflush?: () => void;\n\t};\n\tconst throttledOnChangeRef = useRef(null);\n\n\tonChangeRef.current = onChange;\n\n\tuseLayoutEffect(() => {\n\t\tif (crepeRef.current) return;\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\tconst crepe = new Crepe({\n\t\t\troot: container,\n\t\t\tdefaultValue: initialValueRef.current,\n\t\t\tfeatureConfigs: {\n\t\t\t\t[CrepeFeature.Placeholder]: {\n\t\t\t\t\ttext: placeholder,\n\t\t\t\t},\n\t\t\t\t...(uploadImage\n\t\t\t\t\t? {\n\t\t\t\t\t\t\t[CrepeFeature.ImageBlock]: {\n\t\t\t\t\t\t\t\tonUpload: async (file: File) => {\n\t\t\t\t\t\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\t\t\t\t\t\treturn url;\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t}\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t});\n\n\t\t// Prepare throttled onChange once per editor instance\n\t\tthrottledOnChangeRef.current = throttle((markdown: string) => {\n\t\t\tif (onChangeRef.current) onChangeRef.current(markdown);\n\t\t}, 200);\n\n\t\tcrepe.editor\n\t\t\t.config((ctx) => {\n\t\t\t\tctx.get(listenerCtx).markdownUpdated((_, markdown) => {\n\t\t\t\t\tthrottledOnChangeRef.current?.(markdown);\n\t\t\t\t});\n\t\t\t})\n\t\t\t.use(listener);\n\n\t\tcrepe.create().then(() => {\n\t\t\tisReadyRef.current = true;\n\t\t\tsetIsReady(true);\n\t\t});\n\t\tcrepeRef.current = crepe;\n\n\t\treturn () => {\n\t\t\ttry {\n\t\t\t\tisReadyRef.current = false;\n\t\t\t\tthrottledOnChangeRef.current?.cancel?.();\n\t\t\t\tthrottledOnChangeRef.current = null;\n\t\t\t\tcrepe.destroy();\n\t\t\t} finally {\n\t\t\t\tcrepeRef.current = null;\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tuseLayoutEffect(() => {\n\t\tif (!isReady) return;\n\t\tif (!crepeRef.current) return;\n\t\tif (typeof value !== \"string\") return;\n\n\t\tlet currentMarkdown: string | undefined;\n\t\ttry {\n\t\t\tcurrentMarkdown = crepeRef.current?.getMarkdown?.();\n\t\t} catch {\n\t\t\t// Editor may not have finished initializing its view/state; skip sync for now\n\t\t\treturn;\n\t\t}\n\n\t\tif (currentMarkdown === value) return;\n\n\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\tif (view?.hasFocus?.() === true) return;\n\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\tconst doc = parser(value);\n\t\t\tif (!doc) return;\n\n\t\t\tconst state = view.state;\n\t\t\tconst selection = state.selection;\n\t\t\tconst from = selection.from;\n\n\t\t\tlet tr = state.tr;\n\t\t\ttr = tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0));\n\n\t\t\tconst docSize = doc.content.size;\n\t\t\tconst safeFrom = Math.max(1, Math.min(from, Math.max(1, docSize - 2)));\n\t\t\ttr = tr.setSelection(Selection.near(tr.doc.resolve(safeFrom)));\n\t\t\tview.dispatch(tr);\n\t\t});\n\t}, [value, isReady]);\n\n\treturn (\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\nimport { Crepe, CrepeFeature } from \"@milkdown/crepe\";\nimport \"@milkdown/crepe/theme/common/style.css\";\nimport \"./markdown-editor-styles.css\";\n\nimport { cn, throttle } from \"../../../utils\";\nimport { editorViewCtx, parserCtx } from \"@milkdown/kit/core\";\nimport { listener, listenerCtx } from \"@milkdown/kit/plugin/listener\";\nimport { Slice } from \"@milkdown/kit/prose/model\";\nimport { Selection } from \"@milkdown/kit/prose/state\";\nimport {\n\tuseLayoutEffect,\n\tuseRef,\n\tuseState,\n\ttype MutableRefObject,\n} from \"react\";\n\nexport interface MarkdownEditorProps {\n\tvalue?: string;\n\tonChange?: (markdown: string) => void;\n\tclassName?: string;\n\t/** Optional image upload handler. When provided, enables image upload in the editor. */\n\tuploadImage?: (file: File) => Promise;\n\t/** Placeholder text shown when the editor is empty. */\n\tplaceholder?: string;\n\t/**\n\t * Optional ref that will be populated with an `insertImage(url)` function.\n\t * Call `insertImageRef.current?.(url)` to programmatically insert an image.\n\t * The URL must be a valid, percent-encoded URL (storage adapters guarantee this).\n\t */\n\tinsertImageRef?: MutableRefObject<((url: string) => void) | null>;\n\t/**\n\t * When provided, clicking the Crepe image block's upload area opens a media\n\t * picker instead of the native file dialog. The callback receives a `setUrl`\n\t * function — call it with the chosen URL to set it into the image block.\n\t * The URL must be a valid, percent-encoded URL (storage adapters guarantee this).\n\t */\n\topenMediaPickerForImageBlock?: (setUrl: (url: string) => void) => void;\n}\n\nexport function MarkdownEditor({\n\tvalue,\n\tonChange,\n\tclassName,\n\tuploadImage,\n\tplaceholder = \"Write something...\",\n\tinsertImageRef,\n\topenMediaPickerForImageBlock,\n}: MarkdownEditorProps) {\n\tconst containerRef = useRef(null);\n\tconst crepeRef = useRef(null);\n\tconst isReadyRef = useRef(false);\n\tconst [isReady, setIsReady] = useState(false);\n\tconst onChangeRef = useRef(onChange);\n\tconst initialValueRef = useRef(value ?? \"\");\n\tconst openMediaPickerRef = useRef(\n\t\topenMediaPickerForImageBlock,\n\t);\n\ttype ThrottledFn = ((markdown: string) => void) & {\n\t\tcancel?: () => void;\n\t\tflush?: () => void;\n\t};\n\tconst throttledOnChangeRef = useRef(null);\n\n\tonChangeRef.current = onChange;\n\topenMediaPickerRef.current = openMediaPickerForImageBlock;\n\n\tuseLayoutEffect(() => {\n\t\tif (crepeRef.current) return;\n\t\tconst container = containerRef.current;\n\t\tif (!container) return;\n\n\t\tconst hasMediaPicker = !!openMediaPickerRef.current;\n\n\t\tconst imageBlockConfig: Record = {};\n\t\tif (uploadImage) {\n\t\t\timageBlockConfig.onUpload = async (file: File) => uploadImage(file);\n\t\t}\n\t\tif (hasMediaPicker) {\n\t\t\timageBlockConfig.blockUploadPlaceholderText = \"Media Picker\";\n\t\t\timageBlockConfig.inlineUploadPlaceholderText = \"Media Picker\";\n\t\t}\n\n\t\tconst crepe = new Crepe({\n\t\t\troot: container,\n\t\t\tdefaultValue: initialValueRef.current,\n\t\t\tfeatureConfigs: {\n\t\t\t\t[CrepeFeature.Placeholder]: {\n\t\t\t\t\ttext: placeholder,\n\t\t\t\t},\n\t\t\t\t...(Object.keys(imageBlockConfig).length > 0\n\t\t\t\t\t? { [CrepeFeature.ImageBlock]: imageBlockConfig }\n\t\t\t\t\t: {}),\n\t\t\t},\n\t\t});\n\n\t\t// Intercept clicks on Crepe image-block upload placeholders so that the\n\t\t// native file dialog is suppressed and the media picker is opened instead.\n\t\tconst interceptHandler = (e: MouseEvent) => {\n\t\t\tif (!openMediaPickerRef.current) return;\n\t\t\tconst target = e.target as Element;\n\t\t\t// Only intercept clicks inside the upload placeholder area.\n\t\t\tconst inPlaceholder = target.closest(\".image-edit .placeholder\");\n\t\t\tif (!inPlaceholder) return;\n\t\t\t// Let the hidden file itself through (shouldn't receive clicks normally).\n\t\t\tif ((target as HTMLElement).matches(\"input\")) return;\n\n\t\t\te.preventDefault();\n\t\t\te.stopPropagation();\n\n\t\t\tconst imageEdit = inPlaceholder.closest(\".image-edit\");\n\t\t\tconst linkInput = imageEdit?.querySelector(\n\t\t\t\t\".link-input-area\",\n\t\t\t) as HTMLInputElement | null;\n\n\t\t\topenMediaPickerRef.current((url: string) => {\n\t\t\t\tif (!linkInput) return;\n\t\t\t\t// Use the native setter so Vue's reactivity picks up the change.\n\t\t\t\tconst nativeSetter = Object.getOwnPropertyDescriptor(\n\t\t\t\t\tHTMLInputElement.prototype,\n\t\t\t\t\t\"value\",\n\t\t\t\t)?.set;\n\t\t\t\tnativeSetter?.call(linkInput, url);\n\t\t\t\tlinkInput.dispatchEvent(new Event(\"input\", { bubbles: true }));\n\t\t\t\tlinkInput.dispatchEvent(\n\t\t\t\t\tnew KeyboardEvent(\"keydown\", { key: \"Enter\", bubbles: true }),\n\t\t\t\t);\n\t\t\t});\n\t\t};\n\t\tcontainer.addEventListener(\"click\", interceptHandler, true);\n\n\t\t// Prepare throttled onChange once per editor instance\n\t\tthrottledOnChangeRef.current = throttle((markdown: string) => {\n\t\t\tif (onChangeRef.current) onChangeRef.current(markdown);\n\t\t}, 200);\n\n\t\tcrepe.editor\n\t\t\t.config((ctx) => {\n\t\t\t\tctx.get(listenerCtx).markdownUpdated((_, markdown) => {\n\t\t\t\t\tthrottledOnChangeRef.current?.(markdown);\n\t\t\t\t});\n\t\t\t})\n\t\t\t.use(listener);\n\n\t\tcrepe.create().then(() => {\n\t\t\tisReadyRef.current = true;\n\t\t\tsetIsReady(true);\n\t\t});\n\t\tcrepeRef.current = crepe;\n\n\t\treturn () => {\n\t\t\tcontainer.removeEventListener(\"click\", interceptHandler, true);\n\t\t\ttry {\n\t\t\t\tisReadyRef.current = false;\n\t\t\t\tthrottledOnChangeRef.current?.cancel?.();\n\t\t\t\tthrottledOnChangeRef.current = null;\n\t\t\t\tcrepe.destroy();\n\t\t\t} finally {\n\t\t\t\tcrepeRef.current = null;\n\t\t\t}\n\t\t};\n\t}, []);\n\n\tuseLayoutEffect(() => {\n\t\tif (!isReady) return;\n\t\tif (!crepeRef.current) return;\n\t\tif (typeof value !== \"string\") return;\n\n\t\tlet currentMarkdown: string | undefined;\n\t\ttry {\n\t\t\tcurrentMarkdown = crepeRef.current?.getMarkdown?.();\n\t\t} catch {\n\t\t\t// Editor may not have finished initializing its view/state; skip sync for now\n\t\t\treturn;\n\t\t}\n\n\t\tif (currentMarkdown === value) return;\n\n\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\tif (view?.hasFocus?.() === true) return;\n\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\tconst doc = parser(value);\n\t\t\tif (!doc) return;\n\n\t\t\tconst state = view.state;\n\t\t\tconst selection = state.selection;\n\t\t\tconst from = selection.from;\n\n\t\t\tlet tr = state.tr;\n\t\t\ttr = tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0));\n\n\t\t\tconst docSize = doc.content.size;\n\t\t\tconst safeFrom = Math.max(1, Math.min(from, Math.max(1, docSize - 2)));\n\t\t\ttr = tr.setSelection(Selection.near(tr.doc.resolve(safeFrom)));\n\t\t\tview.dispatch(tr);\n\t\t});\n\t}, [value, isReady]);\n\n\t// Expose insertImage via ref so the parent can insert images programmatically\n\tuseLayoutEffect(() => {\n\t\tif (!insertImageRef) return;\n\t\tinsertImageRef.current = (url: string) => {\n\t\t\tif (!crepeRef.current || !isReadyRef.current) return;\n\t\t\ttry {\n\t\t\t\tconst currentMarkdown = crepeRef.current.getMarkdown?.() ?? \"\";\n\t\t\t\tconst imageMarkdown = `\\n\\n![](${url})\\n\\n`;\n\t\t\t\tconst newMarkdown = currentMarkdown.trimEnd() + imageMarkdown;\n\t\t\t\tcrepeRef.current.editor.action((ctx) => {\n\t\t\t\t\tconst view = ctx.get(editorViewCtx);\n\t\t\t\t\tconst parser = ctx.get(parserCtx);\n\t\t\t\t\tconst doc = parser(newMarkdown);\n\t\t\t\t\tif (!doc) return;\n\t\t\t\t\tconst state = view.state;\n\t\t\t\t\tconst tr = state.tr.replace(\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tstate.doc.content.size,\n\t\t\t\t\t\tnew Slice(doc.content, 0, 0),\n\t\t\t\t\t);\n\t\t\t\t\tview.dispatch(tr);\n\t\t\t\t});\n\t\t\t\tif (onChangeRef.current) onChangeRef.current(newMarkdown);\n\t\t\t} catch {\n\t\t\t\t// Editor may not be ready yet\n\t\t\t}\n\t\t};\n\t\treturn () => {\n\t\t\tif (insertImageRef) insertImageRef.current = null;\n\t\t};\n\t}, [insertImageRef]);\n\n\treturn (\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/forms/markdown-editor.tsx" }, { @@ -352,7 +352,7 @@ { "path": "btst/blog/client/overrides.ts", "type": "registry:lib", - "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Function used to upload an image and return its URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", + "content": "import type { SerializedPost } from \"../types\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport type { BlogLocalization } from \"./localization\";\n\n/**\n * Props for the overridable blog featured image input component.\n */\nexport interface BlogImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: any;\n}\n\n/**\n * Overridable components and functions for the Blog plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface BlogPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Post card component for displaying a post\n\t */\n\tPostCard?: ComponentType<{\n\t\tpost: SerializedPost;\n\t}>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * This is separate from `imagePicker`, which selects an existing asset URL.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the featured image field.\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * Typical use case: render a preview when a value is set, and a media-picker\n\t * trigger when no value is set.\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n", "target": "src/components/btst/blog/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json index ea31783d..eae95ea4 100644 --- a/packages/stack/registry/btst-cms.json +++ b/packages/stack/registry/btst-cms.json @@ -49,13 +49,13 @@ { "path": "btst/cms/client/components/forms/content-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport type {\n\tFieldConfig,\n\tAutoFormInputComponentProps,\n} from \"@/components/ui/auto-form/types\";\nimport { buildFieldConfigFromJsonSchema as buildFieldConfigBase } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { CMSFileUpload } from \"./file-upload\";\nimport { RelationField } from \"./relation-field\";\n\ninterface ContentFormProps {\n\tcontentType: SerializedContentType;\n\tinitialData?: Record;\n\tinitialSlug?: string;\n\tisEditing?: boolean;\n\tonSubmit: (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => Promise;\n\tonCancel?: () => void;\n}\n\n/**\n * Build field configuration for AutoForm with CMS-specific file upload handling.\n *\n * Uses the shared buildFieldConfigFromJsonSchema from auto-form/helpers as a base,\n * then adds special handling for \"file\" fieldType to inject CMSFileUpload component\n * ONLY if no custom component is provided via fieldComponents.\n *\n * @param jsonSchema - The JSON Schema from the content type (with fieldType embedded in properties)\n * @param uploadImage - The uploadImage function from overrides (for file fields)\n * @param fieldComponents - Custom field components from overrides\n */\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: RelationConfig;\n\t[key: string]: unknown;\n}\n\nfunction buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tuploadImage?: (file: File) => Promise,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n): FieldConfig> {\n\t// Get base config from shared utility (handles fieldType from JSON Schema)\n\tconst baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);\n\n\t// Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists\n\t// Custom fieldComponents take priority - don't override if user provided one\n\tconst properties = jsonSchema.properties as Record<\n\t\tstring,\n\t\tJsonSchemaProperty\n\t>;\n\n\tif (!properties) return baseConfig;\n\n\tfor (const [key, prop] of Object.entries(properties)) {\n\t\t// Handle \"file\" fieldType when there's NO custom component for \"file\"\n\t\tif (prop.fieldType === \"file\" && !fieldComponents?.[\"file\"]) {\n\t\t\t// Use CMSFileUpload as the default file component\n\t\t\tif (!uploadImage) {\n\t\t\t\t// Show a clear error message if uploadImage is not provided\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: () => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tFile upload requires an uploadImage function in CMS\n\t\t\t\t\t\t\toverrides.\n\t\t\t\t\t\t
\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Handle \"relation\" fieldType when there's NO custom component for \"relation\"\n\t\tif (\n\t\t\tprop.fieldType === \"relation\" &&\n\t\t\tprop.relation &&\n\t\t\t!fieldComponents?.[\"relation\"]\n\t\t) {\n\t\t\tconst relationConfig = prop.relation;\n\t\t\tbaseConfig[key] = {\n\t\t\t\t...baseConfig[key],\n\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\treturn baseConfig;\n}\n\n/**\n * Determine the first string field in the schema for slug auto-generation\n */\nfunction findSlugSourceField(\n\tjsonSchema: Record,\n): string | null {\n\tconst properties = jsonSchema.properties as Record;\n\tif (!properties) return null;\n\n\t// Look for common name fields first\n\tconst priorityFields = [\"name\", \"title\", \"heading\", \"label\"];\n\tfor (const field of priorityFields) {\n\t\tif (properties[field]?.type === \"string\") {\n\t\t\treturn field;\n\t\t}\n\t}\n\n\t// Fall back to first string field\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tif (value.type === \"string\") {\n\t\t\treturn key;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport function ContentForm({\n\tcontentType,\n\tinitialData = {},\n\tinitialSlug = \"\",\n\tisEditing = false,\n\tonSubmit,\n\tonCancel,\n}: ContentFormProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tuploadImage,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmitting, setIsSubmitting] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\t// Track if we've already synced prefill data to avoid overwriting user input\n\tconst hasSyncedPrefillRef = useRef(false);\n\n\t// Sync formData with initialData when it changes\n\t// This handles both:\n\t// 1. Editing mode: always sync when item data is loaded (isEditing=true)\n\t// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input\n\t// useState only uses the initial value on mount, so we need this effect for updates\n\tuseEffect(() => {\n\t\tconst hasData = Object.keys(initialData).length > 0;\n\t\t// In edit mode, always sync (user is loading existing data)\n\t\t// In create mode, only sync prefill data once\n\t\tconst shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);\n\n\t\tif (shouldSync) {\n\t\t\tsetFormData(initialData);\n\t\t\tif (!isEditing) {\n\t\t\t\thasSyncedPrefillRef.current = true;\n\t\t\t}\n\t\t}\n\t}, [initialData, isEditing]);\n\n\t// Also sync slug when initialSlug changes\n\tuseEffect(() => {\n\t\tif (isEditing && initialSlug) {\n\t\t\tsetSlug(initialSlug);\n\t\t}\n\t}, [initialSlug, isEditing]);\n\n\t// Parse JSON Schema (now includes fieldType embedded in properties)\n\tconst jsonSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn JSON.parse(contentType.jsonSchema) as Record;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}, [contentType.jsonSchema]);\n\n\t// Convert JSON Schema to Zod schema using formSchemaToZod utility\n\t// This properly handles date fields (format: \"date-time\") and min/max date constraints\n\tconst zodSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn formSchemaToZod(jsonSchema);\n\t\t} catch {\n\t\t\treturn z.object({});\n\t\t}\n\t}, [jsonSchema]);\n\n\t// Build field config for AutoForm (fieldType is now embedded in jsonSchema)\n\tconst fieldConfig = useMemo(\n\t\t() =>\n\t\t\tbuildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents),\n\t\t[jsonSchema, uploadImage, fieldComponents],\n\t);\n\n\t// Find the field to use for slug auto-generation\n\tconst slugSourceField = useMemo(\n\t\t() => findSlugSourceField(jsonSchema),\n\t\t[jsonSchema],\n\t);\n\n\t// Handle form value changes for slug auto-generation\n\tconst handleValuesChange = (values: Record) => {\n\t\tsetFormData(values);\n\n\t\t// Auto-generate slug from source field if not manually edited\n\t\tif (!isEditing && !slugManuallyEdited && slugSourceField) {\n\t\t\tconst sourceValue = values[slugSourceField];\n\t\t\tif (typeof sourceValue === \"string\" && sourceValue.trim()) {\n\t\t\t\tsetSlug(slugify(sourceValue));\n\t\t\t}\n\t\t}\n\t};\n\n\t// Handle form submission\n\tconst handleSubmit = async (data: Record) => {\n\t\tsetSlugError(null);\n\t\tsetSubmitError(null);\n\n\t\tif (!slug.trim()) {\n\t\t\tsetSlugError(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmitting(true);\n\t\ttry {\n\t\t\tawait onSubmit({ slug, data });\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : localization.CMS_TOAST_ERROR;\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmitting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t{/* Slug field */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{!isEditing && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{slugManuallyEdited\n\t\t\t\t\t\t\t\t? localization.CMS_EDITOR_SLUG_MANUAL\n\t\t\t\t\t\t\t\t: localization.CMS_EDITOR_SLUG_AUTO}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetSlugError(null);\n\t\t\t\t\t\tif (!isEditing) {\n\t\t\t\t\t\t\tsetSlugManuallyEdited(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isEditing}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tslugSourceField\n\t\t\t\t\t\t\t? `Auto-generated from ${slugSourceField}`\n\t\t\t\t\t\t\t: \"Enter slug...\"\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{slugError &&

{slugError}

}\n\t\t\t\t

\n\t\t\t\t\t{localization.CMS_LABEL_SLUG_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{/* Submit error message */}\n\t\t\t{submitError && (\n\t\t\t\t
\n\t\t\t\t\t

{submitError}

\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Dynamic form from Zod schema */}\n\t\t\t{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}\n\t\t\t}\n\t\t\t\tvalues={formData as any}\n\t\t\t\tonValuesChange={handleValuesChange as any}\n\t\t\t\tonSubmit={handleSubmit as any}\n\t\t\t\tfieldConfig={fieldConfig as any}\n\t\t\t\tisSubmitting={isSubmitting}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t? localization.CMS_STATUS_SAVING\n\t\t\t\t\t\t: localization.CMS_BUTTON_SAVE\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.CMS_BUTTON_CANCEL}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport type {\n\tFieldConfig,\n\tAutoFormInputComponentProps,\n} from \"@/components/ui/auto-form/types\";\nimport { buildFieldConfigFromJsonSchema as buildFieldConfigBase } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { CMSFileUpload } from \"./file-upload\";\nimport { RelationField } from \"./relation-field\";\n\ninterface ContentFormProps {\n\tcontentType: SerializedContentType;\n\tinitialData?: Record;\n\tinitialSlug?: string;\n\tisEditing?: boolean;\n\tonSubmit: (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => Promise;\n\tonCancel?: () => void;\n}\n\n/**\n * Build field configuration for AutoForm with CMS-specific file upload handling.\n *\n * Uses the shared buildFieldConfigFromJsonSchema from auto-form/helpers as a base,\n * then adds special handling for \"file\" fieldType to inject CMSFileUpload component\n * ONLY if no custom component is provided via fieldComponents.\n *\n * @param jsonSchema - The JSON Schema from the content type (with fieldType embedded in properties)\n * @param uploadImage - The uploadImage function from overrides (for file fields)\n * @param fieldComponents - Custom field components from overrides\n */\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: RelationConfig;\n\t[key: string]: unknown;\n}\n\nfunction buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tuploadImage?: (file: File) => Promise,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n\timagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,\n\timageInputField?: React.ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>,\n): FieldConfig> {\n\t// Get base config from shared utility (handles fieldType from JSON Schema)\n\tconst baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);\n\n\t// Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists\n\t// Custom fieldComponents take priority - don't override if user provided one\n\tconst properties = jsonSchema.properties as Record<\n\t\tstring,\n\t\tJsonSchemaProperty\n\t>;\n\n\tif (!properties) return baseConfig;\n\n\tfor (const [key, prop] of Object.entries(properties)) {\n\t\t// Handle \"file\" fieldType when there's NO custom component for \"file\"\n\t\tif (prop.fieldType === \"file\" && !fieldComponents?.[\"file\"]) {\n\t\t\t// Use CMSFileUpload as the default file component\n\t\t\tif (!uploadImage && !imageInputField) {\n\t\t\t\t// Show a clear error message if neither uploadImage nor imageInputField is provided\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: () => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tFile upload requires an uploadImage or{\" \"}\n\t\t\t\t\t\t\timageInputField function in CMS overrides.\n\t\t\t\t\t\t
\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t Promise.resolve(\"\"))}\n\t\t\t\t\t\t\timageInputField={imageInputField}\n\t\t\t\t\t\t\timagePicker={imagePicker}\n\t\t\t\t\t\t/>\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Handle \"relation\" fieldType when there's NO custom component for \"relation\"\n\t\tif (\n\t\t\tprop.fieldType === \"relation\" &&\n\t\t\tprop.relation &&\n\t\t\t!fieldComponents?.[\"relation\"]\n\t\t) {\n\t\t\tconst relationConfig = prop.relation;\n\t\t\tbaseConfig[key] = {\n\t\t\t\t...baseConfig[key],\n\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\treturn baseConfig;\n}\n\n/**\n * Determine the first string field in the schema for slug auto-generation\n */\nfunction findSlugSourceField(\n\tjsonSchema: Record,\n): string | null {\n\tconst properties = jsonSchema.properties as Record;\n\tif (!properties) return null;\n\n\t// Look for common name fields first\n\tconst priorityFields = [\"name\", \"title\", \"heading\", \"label\"];\n\tfor (const field of priorityFields) {\n\t\tif (properties[field]?.type === \"string\") {\n\t\t\treturn field;\n\t\t}\n\t}\n\n\t// Fall back to first string field\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tif (value.type === \"string\") {\n\t\t\treturn key;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport function ContentForm({\n\tcontentType,\n\tinitialData = {},\n\tinitialSlug = \"\",\n\tisEditing = false,\n\tonSubmit,\n\tonCancel,\n}: ContentFormProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tuploadImage,\n\t\timagePicker,\n\t\timageInputField,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmitting, setIsSubmitting] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\t// Track if we've already synced prefill data to avoid overwriting user input\n\tconst hasSyncedPrefillRef = useRef(false);\n\n\t// Sync formData with initialData when it changes\n\t// This handles both:\n\t// 1. Editing mode: always sync when item data is loaded (isEditing=true)\n\t// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input\n\t// useState only uses the initial value on mount, so we need this effect for updates\n\tuseEffect(() => {\n\t\tconst hasData = Object.keys(initialData).length > 0;\n\t\t// In edit mode, always sync (user is loading existing data)\n\t\t// In create mode, only sync prefill data once\n\t\tconst shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);\n\n\t\tif (shouldSync) {\n\t\t\tsetFormData(initialData);\n\t\t\tif (!isEditing) {\n\t\t\t\thasSyncedPrefillRef.current = true;\n\t\t\t}\n\t\t}\n\t}, [initialData, isEditing]);\n\n\t// Also sync slug when initialSlug changes\n\tuseEffect(() => {\n\t\tif (isEditing && initialSlug) {\n\t\t\tsetSlug(initialSlug);\n\t\t}\n\t}, [initialSlug, isEditing]);\n\n\t// Parse JSON Schema (now includes fieldType embedded in properties)\n\tconst jsonSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn JSON.parse(contentType.jsonSchema) as Record;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}, [contentType.jsonSchema]);\n\n\t// Convert JSON Schema to Zod schema using formSchemaToZod utility\n\t// This properly handles date fields (format: \"date-time\") and min/max date constraints\n\tconst zodSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn formSchemaToZod(jsonSchema);\n\t\t} catch {\n\t\t\treturn z.object({});\n\t\t}\n\t}, [jsonSchema]);\n\n\t// Build field config for AutoForm (fieldType is now embedded in jsonSchema)\n\tconst fieldConfig = useMemo(\n\t\t() =>\n\t\t\tbuildFieldConfigFromJsonSchema(\n\t\t\t\tjsonSchema,\n\t\t\t\tuploadImage,\n\t\t\t\tfieldComponents,\n\t\t\t\timagePicker,\n\t\t\t\timageInputField,\n\t\t\t),\n\t\t[jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],\n\t);\n\n\t// Find the field to use for slug auto-generation\n\tconst slugSourceField = useMemo(\n\t\t() => findSlugSourceField(jsonSchema),\n\t\t[jsonSchema],\n\t);\n\n\t// Handle form value changes for slug auto-generation\n\tconst handleValuesChange = (values: Record) => {\n\t\tsetFormData(values);\n\n\t\t// Auto-generate slug from source field if not manually edited\n\t\tif (!isEditing && !slugManuallyEdited && slugSourceField) {\n\t\t\tconst sourceValue = values[slugSourceField];\n\t\t\tif (typeof sourceValue === \"string\" && sourceValue.trim()) {\n\t\t\t\tsetSlug(slugify(sourceValue));\n\t\t\t}\n\t\t}\n\t};\n\n\t// Handle form submission\n\tconst handleSubmit = async (data: Record) => {\n\t\tsetSlugError(null);\n\t\tsetSubmitError(null);\n\n\t\tif (!slug.trim()) {\n\t\t\tsetSlugError(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmitting(true);\n\t\ttry {\n\t\t\tawait onSubmit({ slug, data });\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : localization.CMS_TOAST_ERROR;\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmitting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t{/* Slug field */}\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t{!isEditing && (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{slugManuallyEdited\n\t\t\t\t\t\t\t\t? localization.CMS_EDITOR_SLUG_MANUAL\n\t\t\t\t\t\t\t\t: localization.CMS_EDITOR_SLUG_AUTO}\n\t\t\t\t\t\t\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\t {\n\t\t\t\t\t\tsetSlug(e.target.value);\n\t\t\t\t\t\tsetSlugError(null);\n\t\t\t\t\t\tif (!isEditing) {\n\t\t\t\t\t\t\tsetSlugManuallyEdited(true);\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tdisabled={isEditing}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tslugSourceField\n\t\t\t\t\t\t\t? `Auto-generated from ${slugSourceField}`\n\t\t\t\t\t\t\t: \"Enter slug...\"\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t\t{slugError &&

{slugError}

}\n\t\t\t\t

\n\t\t\t\t\t{localization.CMS_LABEL_SLUG_DESCRIPTION}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{/* Submit error message */}\n\t\t\t{submitError && (\n\t\t\t\t
\n\t\t\t\t\t

{submitError}

\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t{/* Dynamic form from Zod schema */}\n\t\t\t{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}\n\t\t\t}\n\t\t\t\tvalues={formData as any}\n\t\t\t\tonValuesChange={handleValuesChange as any}\n\t\t\t\tonSubmit={handleSubmit as any}\n\t\t\t\tfieldConfig={fieldConfig as any}\n\t\t\t\tisSubmitting={isSubmitting}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t? localization.CMS_STATUS_SAVING\n\t\t\t\t\t\t: localization.CMS_BUTTON_SAVE\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t\t\t{localization.CMS_BUTTON_CANCEL}\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/forms/content-form.tsx" }, { "path": "btst/cms/client/components/forms/file-upload.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState, useCallback, useEffect, type ChangeEvent } from \"react\";\nimport { toast } from \"sonner\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormItem,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Trash2, Loader2 } from \"lucide-react\";\nimport AutoFormLabel from \"@/components/ui/auto-form/common/label\";\nimport AutoFormTooltip from \"@/components/ui/auto-form/common/tooltip\";\n\n/**\n * Props for the CMSFileUpload component\n */\nexport interface CMSFileUploadProps extends AutoFormInputComponentProps {\n\t/**\n\t * Function to upload an image file and return the URL.\n\t * This is required - consumers must provide an upload implementation.\n\t */\n\tuploadImage: (file: File) => Promise;\n}\n\n/**\n * Default file upload component for CMS image fields.\n *\n * This component:\n * - Accepts image files via file input\n * - Uses the required uploadImage prop to upload and get a URL\n * - Shows a preview of the uploaded image\n * - Allows removing the uploaded image\n *\n * You can use this component directly in your fieldComponents override,\n * or create your own custom component using this as a reference.\n *\n * @example\n * ```tsx\n * // Use the default component with your upload function\n * fieldComponents: {\n * file: (props) => (\n * \n * ),\n * }\n * ```\n */\nexport function CMSFileUpload({\n\tlabel,\n\tisRequired,\n\tfieldConfigItem,\n\tfieldProps,\n\tfield,\n\tuploadImage,\n}: CMSFileUploadProps) {\n\t// Exclude showLabel and value from props spread\n\t// File inputs cannot have their value set programmatically (browser security)\n\tconst {\n\t\tshowLabel: _showLabel,\n\t\tvalue: _value,\n\t\t...safeFieldProps\n\t} = fieldProps;\n\tconst showLabel = _showLabel === undefined ? true : _showLabel;\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [previewUrl, setPreviewUrl] = useState(\n\t\tfield.value || null,\n\t);\n\n\tuseEffect(() => {\n\t\tconst normalizedValue = field.value || null;\n\t\tif (normalizedValue !== previewUrl) {\n\t\t\tsetPreviewUrl(normalizedValue);\n\t\t}\n\t}, [field.value, previewUrl]);\n\n\tconst handleFileChange = useCallback(\n\t\tasync (e: ChangeEvent) => {\n\t\t\tconst file = e.target.files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\t// Check if it's an image\n\t\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\t\ttoast.error(\"Please select an image file\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetIsUploading(true);\n\t\t\ttry {\n\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\tsetPreviewUrl(url);\n\t\t\t\tfield.onChange(url);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Image upload failed:\", error);\n\t\t\t\ttoast.error(\"Failed to upload image\");\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t},\n\t\t[field, uploadImage],\n\t);\n\n\tconst handleRemove = useCallback(() => {\n\t\tsetPreviewUrl(null);\n\t\tfield.onChange(\"\");\n\t}, [field]);\n\n\treturn (\n\t\t\n\t\t\t{showLabel && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!previewUrl && (\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t{isUploading && (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t)}\n\t\t\t{previewUrl && (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tRemove\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tuseState,\n\tuseCallback,\n\tuseEffect,\n\ttype ChangeEvent,\n\ttype ComponentType,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormItem,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Trash2, Loader2 } from \"lucide-react\";\nimport AutoFormLabel from \"@/components/ui/auto-form/common/label\";\nimport AutoFormTooltip from \"@/components/ui/auto-form/common/tooltip\";\n\n/**\n * Props for the CMSFileUpload component\n */\nexport interface CMSFileUploadProps extends AutoFormInputComponentProps {\n\t/**\n\t * Function to upload an image file and return the URL.\n\t * This is required - consumers must provide an upload implementation.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the image field.\n\t * When provided, it replaces the default file-upload input entirely.\n\t */\n\timageInputField?: ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>;\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered as a \"Browse media\" option.\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n}\n\n/**\n * Default file upload component for CMS image fields.\n *\n * This component:\n * - Accepts image files via file input\n * - Uses the required uploadImage prop to upload and get a URL\n * - Shows a preview of the uploaded image\n * - Allows removing the uploaded image\n *\n * You can use this component directly in your fieldComponents override,\n * or create your own custom component using this as a reference.\n *\n * @example\n * ```tsx\n * // Use the default component with your upload function\n * fieldComponents: {\n * file: (props) => (\n * \n * ),\n * }\n * ```\n */\nexport function CMSFileUpload({\n\tlabel,\n\tisRequired,\n\tfieldConfigItem,\n\tfieldProps,\n\tfield,\n\tuploadImage,\n\timageInputField: ImageInputField,\n\timagePicker: ImagePickerTrigger,\n}: CMSFileUploadProps) {\n\t// Exclude showLabel and value from props spread\n\t// File inputs cannot have their value set programmatically (browser security)\n\tconst {\n\t\tshowLabel: _showLabel,\n\t\tvalue: _value,\n\t\t...safeFieldProps\n\t} = fieldProps;\n\tconst showLabel = _showLabel === undefined ? true : _showLabel;\n\n\t// All hooks must be called unconditionally before any early return.\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [previewUrl, setPreviewUrl] = useState(\n\t\tfield.value || null,\n\t);\n\n\tuseEffect(() => {\n\t\tconst normalizedValue = field.value || null;\n\t\tif (normalizedValue !== previewUrl) {\n\t\t\tsetPreviewUrl(normalizedValue);\n\t\t}\n\t}, [field.value, previewUrl]);\n\n\tconst handleFileChange = useCallback(\n\t\tasync (e: ChangeEvent) => {\n\t\t\tconst file = e.target.files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\t\ttoast.error(\"Please select an image file\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetIsUploading(true);\n\t\t\ttry {\n\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\tsetPreviewUrl(url);\n\t\t\t\tfield.onChange(url);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Image upload failed:\", error);\n\t\t\t\ttoast.error(\"Failed to upload image\");\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t},\n\t\t[field, uploadImage],\n\t);\n\n\tconst handleRemove = useCallback(() => {\n\t\tsetPreviewUrl(null);\n\t\tfield.onChange(\"\");\n\t}, [field]);\n\n\t// When a custom imageInputField component is provided via overrides, delegate to it.\n\tif (ImageInputField) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{showLabel && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showLabel && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!previewUrl && (\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isUploading && (\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{ImagePickerTrigger && (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\t\t\tsetPreviewUrl(url);\n\t\t\t\t\t\t\t\t\t\tfield.onChange(url);\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t)}\n\t\t\t{previewUrl && (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tRemove\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/cms/client/components/forms/file-upload.tsx" }, { @@ -199,7 +199,7 @@ { "path": "btst/cms/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\n\t/**\n\t * Function used to upload an image and return its URL.\n\t * Used by the default \"file\" field component.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: CMSLocalization;\n\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the dashboard page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDashboardRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the content list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param typeSlug - The content type slug\n\t * @param context - Route context\n\t */\n\tonBeforeListRendered?: (typeSlug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the content editor page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param typeSlug - The content type slug\n\t * @param id - The content item ID (null for new items)\n\t * @param context - Route context\n\t */\n\tonBeforeEditorRendered?: (\n\t\ttypeSlug: string,\n\t\tid: string | null,\n\t\tcontext: RouteContext,\n\t) => boolean;\n}\n", + "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t * \"Preview\"\n\t * Change} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t *
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered inside the default \"file\" field component as a\n\t * \"Browse media\" option, letting users select a previously uploaded asset.\n\t * Receives `onSelect(url)` — the URL is set as the field value.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: CMSLocalization;\n\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the dashboard page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDashboardRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the content list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param typeSlug - The content type slug\n\t * @param context - Route context\n\t */\n\tonBeforeListRendered?: (typeSlug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the content editor page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param typeSlug - The content type slug\n\t * @param id - The content item ID (null for new items)\n\t * @param context - Route context\n\t */\n\tonBeforeEditorRendered?: (\n\t\ttypeSlug: string,\n\t\tid: string | null,\n\t\tcontext: RouteContext,\n\t) => boolean;\n}\n", "target": "src/components/btst/cms/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-kanban.json b/packages/stack/registry/btst-kanban.json index 18f06e5f..fa67c9ec 100644 --- a/packages/stack/registry/btst-kanban.json +++ b/packages/stack/registry/btst-kanban.json @@ -20,7 +20,7 @@ "card", "dialog", "dropdown-menu", - "https://raw.githubusercontent.com/olliethedev/shadcn-minimal-tiptap/refs/heads/feat/markdown/registry/block-registry.json", + "https://raw.githubusercontent.com/olliethedev/shadcn-minimal-tiptap/refs/heads/feat/image-upload-config/registry/block-registry.json", "input", "label", "select", @@ -49,7 +49,7 @@ { "path": "btst/kanban/client/components/forms/board-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Label } from \"@/components/ui/label\";\nimport { useBoardMutations } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { SerializedBoard } from \"../../../types\";\n\ninterface BoardFormProps {\n\tboard?: SerializedBoard;\n\tonClose: () => void;\n\tonSuccess: (boardId: string) => void;\n}\n\nexport function BoardForm({ board, onClose, onSuccess }: BoardFormProps) {\n\tconst isEditing = !!board;\n\tconst { createBoard, updateBoard, isCreating, isUpdating } =\n\t\tuseBoardMutations();\n\n\tconst [name, setName] = useState(board?.name || \"\");\n\tconst [description, setDescription] = useState(board?.description || \"\");\n\tconst [error, setError] = useState(null);\n\n\tconst isPending = isCreating || isUpdating;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!name.trim()) {\n\t\t\tsetError(\"Name is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && board) {\n\t\t\t\tawait updateBoard(board.id, { name, description });\n\t\t\t\tonSuccess(board.id);\n\t\t\t} else {\n\t\t\t\tconst newBoard = await createBoard({ name, description });\n\t\t\t\tif (newBoard?.id) {\n\t\t\t\t\tonSuccess(newBoard.id);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetName(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Project Alpha\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetDescription(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"Describe your board...\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\trows={3}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tCancel\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Label } from \"@/components/ui/label\";\nimport { useBoardMutations } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { SerializedBoard } from \"../../../types\";\n\ninterface BoardFormProps {\n\tboard?: SerializedBoard;\n\tonClose: () => void;\n\tonSuccess: (boardId: string) => void;\n}\n\nexport function BoardForm({ board, onClose, onSuccess }: BoardFormProps) {\n\tconst isEditing = !!board;\n\tconst { createBoard, updateBoard, isCreating, isUpdating } =\n\t\tuseBoardMutations();\n\n\tconst [name, setName] = useState(board?.name || \"\");\n\tconst [description, setDescription] = useState(board?.description || \"\");\n\tconst [error, setError] = useState(null);\n\n\tconst isPending = isCreating || isUpdating;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!name.trim()) {\n\t\t\tsetError(\"Name is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && board) {\n\t\t\t\tawait updateBoard(board.id, { name, description });\n\t\t\t\tonSuccess(board.id);\n\t\t\t} else {\n\t\t\t\tconst newBoard = await createBoard({ name, description });\n\t\t\t\tif (newBoard?.id) {\n\t\t\t\t\tonSuccess(newBoard.id);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetName(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Project Alpha\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetDescription(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"Describe your board...\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\trows={3}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tCancel\n\t\t\t\t\n\t\t\t
\n\t\t
\n\t);\n}\n", "target": "src/components/btst/kanban/client/components/forms/board-form.tsx" }, { @@ -61,7 +61,7 @@ { "path": "btst/kanban/client/components/forms/task-form.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setPriority(v as Priority)}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst { uploadImage, imagePicker: imagePickerTrigger } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t setPriority(v as Priority)}\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t\tuploader={uploadImage}\n\t\t\t\t\timagePickerTrigger={imagePickerTrigger}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t
\n\t);\n}\n", "target": "src/components/btst/kanban/client/components/forms/task-form.tsx" }, { @@ -193,7 +193,7 @@ { "path": "btst/kanban/client/overrides.ts", "type": "registry:lib", - "content": "import type { ComponentType, ReactNode } from \"react\";\nimport type { KanbanLocalization } from \"./localization\";\nimport type { SerializedTask } from \"../types\";\n\n/**\n * User information for assignee display/selection\n * Framework-agnostic - consumers map their auth system to this shape\n */\nexport interface KanbanUser {\n\tid: string;\n\tname: string;\n\tavatarUrl?: string;\n\temail?: string;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { boardId: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Kanban plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface KanbanPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Localization object for the kanban plugin\n\t */\n\tlocalization?: KanbanLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// ============ User Resolution (required for assignee features) ============\n\n\t/**\n\t * Resolve user info from an assigneeId\n\t * Called when rendering task cards/forms that have an assignee\n\t * Return null for unknown users (will show fallback UI)\n\t */\n\tresolveUser: (\n\t\tuserId: string,\n\t) => Promise | KanbanUser | null;\n\n\t/**\n\t * Search/list users available for assignment\n\t * Called when user opens the assignee picker\n\t * @param query - Search query (empty string for initial load)\n\t * @param boardId - Optional board context for scoped user lists\n\t */\n\tsearchUsers: (\n\t\tquery: string,\n\t\tboardId?: string,\n\t) => Promise | KanbanUser[];\n\n\t// ============ Lifecycle Hooks (optional) ============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'boards', 'board', 'newBoard')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the boards list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeBoardsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param boardId - The board ID\n\t * @param context - Route context\n\t */\n\tonBeforeBoardPageRendered?: (\n\t\tboardId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the new board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewBoardPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered at the bottom of the task detail dialog.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the kanban plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * kanban: {\n\t * taskDetailBottomSlot: (task) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\ttaskDetailBottomSlot?: (task: SerializedTask) => ReactNode;\n}\n", + "content": "import type { ComponentType, ReactNode } from \"react\";\nimport type { KanbanLocalization } from \"./localization\";\nimport type { SerializedTask } from \"../types\";\n\n/**\n * User information for assignee display/selection\n * Framework-agnostic - consumers map their auth system to this shape\n */\nexport interface KanbanUser {\n\tid: string;\n\tname: string;\n\tavatarUrl?: string;\n\temail?: string;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { boardId: \"abc123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the Kanban plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface KanbanPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\t/**\n\t * Localization object for the kanban plugin\n\t */\n\tlocalization?: KanbanLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t/**\n\t * Function used to upload a new image file from the task description editor\n\t * and return its URL. This is separate from `imagePicker`, which selects an\n\t * existing asset URL.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it appears inside the image insertion dialog of the task description editor,\n\t * letting users browse and select previously uploaded assets.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\n\t// ============ User Resolution (required for assignee features) ============\n\n\t/**\n\t * Resolve user info from an assigneeId\n\t * Called when rendering task cards/forms that have an assignee\n\t * Return null for unknown users (will show fallback UI)\n\t */\n\tresolveUser: (\n\t\tuserId: string,\n\t) => Promise | KanbanUser | null;\n\n\t/**\n\t * Search/list users available for assignment\n\t * Called when user opens the assignee picker\n\t * @param query - Search query (empty string for initial load)\n\t * @param boardId - Optional board context for scoped user lists\n\t */\n\tsearchUsers: (\n\t\tquery: string,\n\t\tboardId?: string,\n\t) => Promise | KanbanUser[];\n\n\t// ============ Lifecycle Hooks (optional) ============\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'boards', 'board', 'newBoard')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the boards list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeBoardsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param boardId - The board ID\n\t * @param context - Route context\n\t */\n\tonBeforeBoardPageRendered?: (\n\t\tboardId: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the new board page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewBoardPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered at the bottom of the task detail dialog.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the kanban plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * kanban: {\n\t * taskDetailBottomSlot: (task) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\ttaskDetailBottomSlot?: (task: SerializedTask) => ReactNode;\n}\n", "target": "src/components/btst/kanban/client/overrides.ts" }, { diff --git a/packages/stack/registry/btst-media.json b/packages/stack/registry/btst-media.json new file mode 100644 index 00000000..1fd23145 --- /dev/null +++ b/packages/stack/registry/btst-media.json @@ -0,0 +1,117 @@ +{ + "name": "btst-media", + "type": "registry:block", + "title": "Media Plugin Pages", + "description": "Ejectable page components for the @btst/stack media plugin. Customize the UI layer while keeping data-fetching in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack", + "@vercel/blob" + ], + "registryDependencies": [ + "button", + "dialog", + "input", + "popover", + "tabs" + ], + "files": [ + { + "path": "btst/media/types.ts", + "type": "registry:lib", + "content": "export type Asset = {\n\tid: string;\n\tfilename: string;\n\toriginalName: string;\n\tmimeType: string;\n\tsize: number;\n\turl: string;\n\tfolderId?: string;\n\talt?: string;\n\tcreatedAt: Date;\n};\n\nexport type Folder = {\n\tid: string;\n\tname: string;\n\tparentId?: string;\n\tcreatedAt: Date;\n};\n\nexport interface SerializedAsset extends Omit {\n\tcreatedAt: string;\n}\n\nexport interface SerializedFolder extends Omit {\n\tcreatedAt: string;\n}\n", + "target": "src/components/btst/media/types.ts" + }, + { + "path": "btst/media/schemas.ts", + "type": "registry:lib", + "content": "import { z } from \"zod\";\n\nexport const AssetListQuerySchema = z.object({\n\tfolderId: z.string().optional(),\n\tmimeType: z.string().optional(),\n\tquery: z.string().optional(),\n\toffset: z.coerce.number().int().min(0).optional(),\n\tlimit: z.coerce.number().int().min(1).max(100).optional(),\n});\n\nexport const createAssetSchema = z.object({\n\tfilename: z.string().min(1),\n\toriginalName: z.string().min(1),\n\tmimeType: z.string().min(1),\n\t// Allow 0 for URL-registered assets where size is unknown at registration time.\n\tsize: z.number().int().min(0),\n\turl: z.httpUrl(),\n\tfolderId: z.string().optional(),\n\talt: z.string().optional(),\n});\n\nexport const updateAssetSchema = z.object({\n\talt: z.string().optional(),\n\tfolderId: z.string().nullable().optional(),\n});\n\nexport const createFolderSchema = z.object({\n\tname: z.string().min(1),\n\tparentId: z.string().optional(),\n});\n\nexport const uploadTokenRequestSchema = z.object({\n\tfilename: z.string().min(1),\n\tmimeType: z.string().min(1),\n\tsize: z.number().int().positive(),\n\tfolderId: z.string().optional(),\n});\n", + "target": "src/components/btst/media/schemas.ts" + }, + { + "path": "btst/media/client/components/media-picker/asset-card.tsx", + "type": "registry:component", + "content": "import { useDeleteAsset } from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { cn } from \"@/lib/utils\";\nimport { File, Check, Copy, Trash2 } from \"lucide-react\";\nimport { isImage, formatBytes } from \"./utils\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { MediaPluginOverrides } from \"../../overrides\";\nimport { AssetPreviewButton } from \"./asset-preview-button\";\nimport { toast } from \"sonner\";\n\nexport function AssetCard({\n\tasset,\n\tonToggle,\n\tselected = false,\n\tonDelete,\n\tapiBaseURL,\n}: {\n\tasset: SerializedAsset;\n\tselected?: boolean;\n\tonToggle?: () => void;\n\tonDelete?: (id: string) => void | Promise;\n\tapiBaseURL?: string;\n}) {\n\tconst { mutateAsync: deleteAsset } = useDeleteAsset();\n\tconst { Image: ImageComponent } = usePluginOverrides<\n\t\tMediaPluginOverrides,\n\t\tPartial\n\t>(\"media\", {});\n\tconst imageAsset = isImage(asset.mimeType);\n\tconst selectable = typeof onToggle === \"function\";\n\n\tconst copyUrl = () => {\n\t\tlet fullUrl: string;\n\t\ttry {\n\t\t\tfullUrl = new URL(asset.url, apiBaseURL).href;\n\t\t} catch {\n\t\t\tfullUrl = asset.url;\n\t\t}\n\t\tnavigator.clipboard\n\t\t\t.writeText(fullUrl)\n\t\t\t.then(() => toast.success(\"URL copied\"));\n\t};\n\n\tconst handleDelete = () => {\n\t\tif (onDelete) {\n\t\t\treturn onDelete(asset.id);\n\t\t}\n\n\t\tif (confirm(`Delete \"${asset.originalName}\"?`)) {\n\t\t\treturn deleteAsset(asset.id).catch(console.error);\n\t\t}\n\t};\n\n\treturn (\n\t\t {\n\t\t\t\tif (selectable && (e.key === \"Enter\" || e.key === \" \")) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tonToggle();\n\t\t\t\t}\n\t\t\t}}\n\t\t\tclassName={cn(\n\t\t\t\t\"group relative cursor-pointer rounded-md border bg-muted/30 p-1 transition-all hover:border-ring hover:shadow-sm\",\n\t\t\t\t!selectable && \"cursor-default\",\n\t\t\t\tselected && \"border-ring ring-1 ring-ring\",\n\t\t\t)}\n\t\t>\n\t\t\t{/* Thumbnail */}\n\t\t\t
\n\t\t\t\t{imageAsset ? (\n\t\t\t\t\tImageComponent ? (\n\t\t\t\t\t\t\n\t\t\t\t\t) : (\n\t\t\t\t\t\t\n\t\t\t\t\t)\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{/* Name + size */}\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t{asset.originalName}\n\t\t\t\t

\n\t\t\t\t

\n\t\t\t\t\t{formatBytes(asset.size)}\n\t\t\t\t

\n\t\t\t
\n\n\t\t\t{/* Selection indicator */}\n\t\t\t{selected && (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/asset-card.tsx" + }, + { + "path": "btst/media/client/components/media-picker/asset-preview-button.tsx", + "type": "registry:component", + "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n\tDialog,\n\tDialogClose,\n\tDialogContent,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Eye, X } from \"lucide-react\";\nimport type { SerializedAsset } from \"../../../types\";\n\nexport function AssetPreviewButton({\n\tasset,\n\tclassName,\n}: {\n\tasset: SerializedAsset;\n\tclassName: string;\n}) {\n\tconst [open, setOpen] = useState(false);\n\n\treturn (\n\t\t<>\n\t\t\t {\n\t\t\t\t\tevent.stopPropagation();\n\t\t\t\t\tsetOpen(true);\n\t\t\t\t}}\n\t\t\t\tclassName={className}\n\t\t\t>\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{asset.alt || asset.originalName}\n\t\t\t\t\t\n\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/asset-preview-button.tsx" + }, + { + "path": "btst/media/client/components/media-picker/browse-tab.tsx", + "type": "registry:component", + "content": "import { useState, useRef } from \"react\";\nimport { useAssets } from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2, Search, X, Image } from \"lucide-react\";\nimport { AssetCard } from \"./asset-card\";\nimport { matchesAccept } from \"./utils\";\n\nexport function BrowseTab({\n\tfolderId,\n\tselected = [],\n\taccept,\n\tonToggle,\n\tonDelete,\n\tapiBaseURL,\n\temptyMessage = \"No files found\",\n}: {\n\tfolderId: string | null;\n\tselected?: SerializedAsset[];\n\taccept?: string[];\n\tonToggle?: (asset: SerializedAsset) => void;\n\tonDelete?: (id: string) => void | Promise;\n\tapiBaseURL?: string;\n\temptyMessage?: string;\n}) {\n\tconst [search, setSearch] = useState(\"\");\n\tconst [debouncedSearch, setDebouncedSearch] = useState(\"\");\n\tconst debounceRef = useRef | null>(null);\n\tconst selectable = typeof onToggle === \"function\";\n\n\tconst handleSearch = (v: string) => {\n\t\tsetSearch(v);\n\t\tif (debounceRef.current) clearTimeout(debounceRef.current);\n\t\tdebounceRef.current = setTimeout(() => setDebouncedSearch(v), 300);\n\t};\n\n\tconst { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =\n\t\tuseAssets({\n\t\t\tfolderId: folderId ?? undefined,\n\t\t\tquery: debouncedSearch || undefined,\n\t\t\tlimit: 40,\n\t\t});\n\n\tconst allAssets = data?.pages.flatMap((p) => p.items) ?? [];\n\tconst filtered = accept\n\t\t? allAssets.filter((a) => matchesAccept(a.mimeType, accept))\n\t\t: allAssets;\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t handleSearch(e.target.value)}\n\t\t\t\t\tplaceholder=\"Search files…\"\n\t\t\t\t\tclassName=\"h-8 pl-7 text-sm\"\n\t\t\t\t/>\n\t\t\t\t{search && (\n\t\t\t\t\t {\n\t\t\t\t\t\t\tsetSearch(\"\");\n\t\t\t\t\t\t\tsetDebouncedSearch(\"\");\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{isLoading ? (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t) : filtered.length === 0 ? (\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

{emptyMessage}

\n\t\t\t\t
\n\t\t\t) : (\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t{filtered.map((asset) => (\n\t\t\t\t\t\t\t s.id === asset.id)}\n\t\t\t\t\t\t\t\tonToggle={selectable ? () => onToggle(asset) : undefined}\n\t\t\t\t\t\t\t\tonDelete={onDelete}\n\t\t\t\t\t\t\t\tapiBaseURL={apiBaseURL}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t\t{hasNextPage && (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t fetchNextPage()}\n\t\t\t\t\t\t\t\tdisabled={isFetchingNextPage}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{isFetchingNextPage ? (\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\tLoad more\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t)}\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/browse-tab.tsx" + }, + { + "path": "btst/media/client/components/media-picker/folder-tree.tsx", + "type": "registry:component", + "content": "import { useState } from \"react\";\nimport {\n\tuseFolders,\n\tuseCreateFolder,\n\tuseDeleteFolder,\n} from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedFolder } from \"../../../types\";\nimport { FolderPlus } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Check, Folder, Trash2, ChevronRight, FolderOpen } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport function FolderTree({\n\tselectedId,\n\tonSelect,\n}: {\n\tselectedId: string | null;\n\tonSelect: (id: string | null) => void;\n}) {\n\tconst { data: rootFoldersRaw = [] } = useFolders(null);\n\tconst rootFolders =\n\t\trootFoldersRaw as import(\"../../../types\").SerializedFolder[];\n\tconst [newFolderName, setNewFolderName] = useState(\"\");\n\tconst [isCreating, setIsCreating] = useState(false);\n\tconst { mutateAsync: createFolder } = useCreateFolder();\n\tconst { mutateAsync: deleteFolder } = useDeleteFolder();\n\n\tconst handleCreateFolder = async () => {\n\t\tconst name = newFolderName.trim();\n\t\tif (!name) return;\n\t\ttry {\n\t\t\tawait createFolder({ name, parentId: selectedId ?? undefined });\n\t\t\tsetNewFolderName(\"\");\n\t\t\tsetIsCreating(false);\n\t\t} catch (err) {\n\t\t\tconsole.error(\"[btst/media] Failed to create folder\", err);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\tFolders\n\t\t\t\t\n\t\t\t\t setIsCreating((v) => !v)}\n\t\t\t\t\tclassName=\"rounded p-0.5 hover:bg-muted\"\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{isCreating && (\n\t\t\t\t
\n\t\t\t\t\t setNewFolderName(e.target.value)}\n\t\t\t\t\t\tplaceholder=\"Folder name\"\n\t\t\t\t\t\tclassName=\"h-6 text-xs\"\n\t\t\t\t\t\tonKeyDown={(e) => {\n\t\t\t\t\t\t\tif (e.key === \"Enter\") void handleCreateFolder();\n\t\t\t\t\t\t\tif (e.key === \"Escape\") setIsCreating(false);\n\t\t\t\t\t\t}}\n\t\t\t\t\t/>\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t{/* All assets (root) */}\n\t\t\t\t onSelect(null)}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted\",\n\t\t\t\t\t\tselectedId === null && \"bg-muted font-medium\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tAll files\n\t\t\t\t\n\n\t\t\t\t{rootFolders.map((folder) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t\t
\n\n\t\t\t{selectedId && (\n\t\t\t\t
\n\t\t\t\t\t {\n\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\tconfirm(\"Delete this folder? Assets inside will be unaffected.\")\n\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\tawait deleteFolder(selectedId);\n\t\t\t\t\t\t\t\t\tonSelect(null);\n\t\t\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\t\t\tconsole.error(\"[btst/media] Failed to delete folder\", err);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}}\n\t\t\t\t\t\tclassName=\"flex items-center gap-1 text-xs text-destructive hover:underline\"\n\t\t\t\t\t>\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete folder\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t)}\n\t\t
\n\t);\n}\n\nexport function FolderTreeItem({\n\tfolder,\n\tselectedId,\n\tonSelect,\n\tdepth = 0,\n}: {\n\tfolder: SerializedFolder;\n\tselectedId: string | null;\n\tonSelect: (id: string | null) => void;\n\tdepth?: number;\n}) {\n\tconst [expanded, setExpanded] = useState(false);\n\tconst { data: children = [] } = useFolders(folder.id);\n\n\treturn (\n\t\t
\n\t\t\t {\n\t\t\t\t\tonSelect(folder.id);\n\t\t\t\t\tsetExpanded((v) => !v);\n\t\t\t\t}}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted\",\n\t\t\t\t\tselectedId === folder.id && \"bg-muted font-medium\",\n\t\t\t\t)}\n\t\t\t\tstyle={{ paddingLeft: `${8 + depth * 12}px` }}\n\t\t\t>\n\t\t\t\t{children.length > 0 ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{expanded ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t{folder.name}\n\t\t\t\n\t\t\t{expanded &&\n\t\t\t\tchildren.map((child) => (\n\t\t\t\t\t\n\t\t\t\t))}\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/folder-tree.tsx" + }, + { + "path": "btst/media/client/components/media-picker/index.tsx", + "type": "registry:component", + "content": "\"use client\";\nimport { useState, type ReactNode } from \"react\";\nimport {\n\tPopover,\n\tPopoverContent,\n\tPopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tTabs,\n\tTabsContent,\n\tTabsList,\n\tTabsTrigger,\n} from \"@/components/ui/tabs\";\nimport { Image, Upload, Link, X } from \"lucide-react\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { FolderTree } from \"./folder-tree\";\nimport { BrowseTab } from \"./browse-tab\";\nimport { UploadTab } from \"./upload-tab\";\nimport { UrlTab } from \"./url-tab\";\nimport type { MediaPluginOverrides } from \"../../overrides\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\n\nexport interface MediaPickerProps {\n\t/**\n\t * Element that triggers opening the picker. Required.\n\t */\n\ttrigger: ReactNode;\n\t/**\n\t * Called when the user confirms their selection.\n\t */\n\tonSelect: (assets: SerializedAsset[]) => void;\n\t/**\n\t * Allow multiple selection.\n\t * @default false\n\t */\n\tmultiple?: boolean;\n\t/**\n\t * Filter displayed assets by MIME type prefix (e.g. \"image/\").\n\t */\n\taccept?: string[];\n}\n\n/**\n * MediaPicker — a Popover-based media browser.\n *\n * Reads API config from the `media` plugin overrides context (set up in StackProvider).\n * Must be rendered inside a `StackProvider` that includes media overrides.\n *\n * @example\n * ```tsx\n * Browse media}\n * accept={[\"image/*\"]}\n * onSelect={(assets) => form.setValue(\"image\", assets[0].url)}\n * />\n * ```\n */\nexport function MediaPicker({\n\ttrigger,\n\tonSelect,\n\tmultiple = false,\n\taccept,\n}: MediaPickerProps) {\n\tconst [open, setOpen] = useState(false);\n\tconst [selectedFolder, setSelectedFolder] = useState(null);\n\tconst [selectedAssets, setSelectedAssets] = useState([]);\n\tconst [activeTab, setActiveTab] = useState<\"browse\" | \"upload\" | \"url\">(\n\t\t\"browse\",\n\t);\n\n\tconst handleClose = () => {\n\t\tsetOpen(false);\n\t\tsetSelectedAssets([]);\n\t};\n\n\tconst handleConfirm = () => {\n\t\tif (selectedAssets.length === 0) return;\n\t\t// Copy selection before clearing; defer onSelect so the popover has time\n\t\t// to start its close animation before any parent state updates that might\n\t\t// unmount this component (e.g. CMSFileUpload hiding when previewUrl is set).\n\t\tconst toSelect = [...selectedAssets];\n\t\thandleClose();\n\t\tsetTimeout(() => onSelect(toSelect), 0);\n\t};\n\n\tconst handleToggleAsset = (asset: SerializedAsset) => {\n\t\tif (multiple) {\n\t\t\tsetSelectedAssets((prev) =>\n\t\t\t\tprev.some((a) => a.id === asset.id)\n\t\t\t\t\t? prev.filter((a) => a.id !== asset.id)\n\t\t\t\t\t: [...prev, asset],\n\t\t\t);\n\t\t} else {\n\t\t\tsetSelectedAssets([asset]);\n\t\t}\n\t};\n\n\tconst handleUploaded = (asset: SerializedAsset) => {\n\t\tif (multiple) {\n\t\t\tsetSelectedAssets((prev) => [...prev, asset]);\n\t\t} else {\n\t\t\tsetSelectedAssets([asset]);\n\t\t\tsetActiveTab(\"browse\");\n\t\t}\n\t};\n\n\tconst handleUrlRegistered = (asset: SerializedAsset) => {\n\t\t// Close the popover first, then notify parent — same deferral as handleConfirm.\n\t\tconst toSelect = asset;\n\t\thandleClose();\n\t\tsetTimeout(() => onSelect([toSelect]), 0);\n\t};\n\n\treturn (\n\t\t {\n\t\t\t\tif (!v) handleClose();\n\t\t\t\telse setOpen(true);\n\t\t\t}}\n\t\t>\n\t\t\t{trigger}\n\t\t\t\n\t\t\t\t
\n\t\t\t\t\t{/* Header */}\n\t\t\t\t\t
\n\t\t\t\t\t\tMedia Library\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Body */}\n\t\t\t\t\t
\n\t\t\t\t\t\t{/* Folder sidebar */}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\n\t\t\t\t\t\t{/* Main panel */}\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t setActiveTab(v as any)}\n\t\t\t\t\t\t\t\tclassName=\"flex flex-1 flex-col min-h-0\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tBrowse\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tUpload\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tURL\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\n\t\t\t\t\t{/* Footer */}\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{selectedAssets.length > 0\n\t\t\t\t\t\t\t\t? `${selectedAssets.length} selected`\n\t\t\t\t\t\t\t\t: \"Click a file to select it\"}\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{multiple\n\t\t\t\t\t\t\t\t\t? `Select ${selectedAssets.length > 0 ? `(${selectedAssets.length})` : \"\"}`\n\t\t\t\t\t\t\t\t\t: \"Select\"}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\n/**\n * ImageInputField — displays an image preview with change/remove actions, or a\n * \"Browse Media\" button that opens the full MediaPicker popover (Browse / Upload / URL tabs).\n *\n * Upload mode, folder selection, and multi-mode cloud support are all handled inside\n * the MediaPicker's UploadTab — this component is purely a thin wrapper.\n */\nexport function ImageInputField({\n\tvalue,\n\tonChange,\n}: {\n\tvalue: string;\n\tonChange: (v: string) => void;\n}) {\n\tconst { Image: ImageComponent } = usePluginOverrides<\n\t\tMediaPluginOverrides,\n\t\tPartial\n\t>(\"media\", {});\n\n\tif (value) {\n\t\treturn (\n\t\t\t
\n\t\t\t\t{ImageComponent ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\t\tChange Image\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t}\n\t\t\t\t\t\taccept={[\"image/*\"]}\n\t\t\t\t\t\tonSelect={(assets) => onChange(assets[0]?.url ?? \"\")}\n\t\t\t\t\t/>\n\t\t\t\t\t onChange(\"\")}\n\t\t\t\t\t>\n\t\t\t\t\t\tRemove\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t);\n\t}\n\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t\t\tBrowse Media\n\t\t\t\t\t\n\t\t\t\t}\n\t\t\t\taccept={[\"image/*\"]}\n\t\t\t\tonSelect={(assets) => onChange(assets[0]?.url ?? \"\")}\n\t\t\t/>\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/index.tsx" + }, + { + "path": "btst/media/client/components/media-picker/upload-tab.tsx", + "type": "registry:component", + "content": "import { useState, useCallback, useRef } from \"react\";\nimport { useUploadAsset } from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2, Upload } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { matchesAccept } from \"./utils\";\n\nexport function UploadTab({\n\tfolderId,\n\taccept,\n\tonUploaded,\n}: {\n\tfolderId: string | null;\n\taccept?: string[];\n\tonUploaded: (asset: SerializedAsset) => void;\n}) {\n\tconst [dragging, setDragging] = useState(false);\n\tconst [uploading, setUploading] = useState(false);\n\tconst [error, setError] = useState(null);\n\tconst fileInputRef = useRef(null);\n\tconst { mutateAsync: uploadAsset } = useUploadAsset();\n\n\tconst acceptAttr = accept?.join(\",\") ?? undefined;\n\n\tconst handleFiles = useCallback(\n\t\tasync (files: FileList | File[]) => {\n\t\t\tconst fileArr = Array.from(files);\n\t\t\tif (fileArr.length === 0) return;\n\t\t\tsetError(null);\n\t\t\tsetUploading(true);\n\t\t\ttry {\n\t\t\t\tfor (const file of fileArr) {\n\t\t\t\t\tif (accept && !matchesAccept(file.type, accept)) {\n\t\t\t\t\t\tsetError(`File type ${file.type} is not accepted.`);\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tconst asset = await uploadAsset({\n\t\t\t\t\t\tfile,\n\t\t\t\t\t\tfolderId: folderId ?? undefined,\n\t\t\t\t\t});\n\t\t\t\t\tonUploaded(asset);\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\tsetError(err instanceof Error ? err.message : \"Upload failed\");\n\t\t\t} finally {\n\t\t\t\tsetUploading(false);\n\t\t\t}\n\t\t},\n\t\t[accept, folderId, uploadAsset, onUploaded],\n\t);\n\n\treturn (\n\t\t
\n\t\t\t {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tsetDragging(true);\n\t\t\t\t}}\n\t\t\t\tonDragLeave={() => setDragging(false)}\n\t\t\t\tonDrop={(e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tsetDragging(false);\n\t\t\t\t\tvoid handleFiles(e.dataTransfer.files);\n\t\t\t\t}}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex flex-1 flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors sm:px-6\",\n\t\t\t\t\tdragging ? \"border-ring bg-ring/5\" : \"border-muted-foreground/30\",\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{uploading ? (\n\t\t\t\t\t<>\n\t\t\t\t\t\t\n\t\t\t\t\t\t

Uploading…

\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t<>\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

Drop files here

\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\tor click to browse\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tChoose files\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t
\n\t\t\t{error &&

{error}

}\n\t\t\t e.target.files && handleFiles(e.target.files)}\n\t\t\t/>\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/upload-tab.tsx" + }, + { + "path": "btst/media/client/components/media-picker/url-tab.tsx", + "type": "registry:component", + "content": "import { useState } from \"react\";\nimport { useRegisterAsset } from \"@btst/stack/plugins/media/client/hooks\";\nimport type { SerializedAsset } from \"../../../types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2, Check } from \"lucide-react\";\n\nexport function UrlTab({\n\tfolderId,\n\tonRegistered,\n}: {\n\tfolderId: string | null;\n\tonRegistered: (asset: SerializedAsset) => void;\n}) {\n\tconst [url, setUrl] = useState(\"\");\n\tconst [error, setError] = useState(null);\n\tconst { mutateAsync: registerAsset, isPending } = useRegisterAsset();\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\t\tconst trimmed = url.trim();\n\t\tif (!trimmed) return;\n\t\ttry {\n\t\t\tconst filename = trimmed.split(\"/\").pop() ?? \"asset\";\n\t\t\tconst asset = await registerAsset({\n\t\t\t\turl: trimmed,\n\t\t\t\tfilename,\n\t\t\t\tfolderId: folderId ?? undefined,\n\t\t\t});\n\t\t\tsetUrl(\"\");\n\t\t\tonRegistered(asset);\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"Failed to register URL\");\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t

\n\t\t\t\tPaste a public URL to register it as an asset without uploading a file.\n\t\t\t

\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t setUrl(e.target.value)}\n\t\t\t\t\t\tplaceholder=\"https://example.com/image.png\"\n\t\t\t\t\t\tclassName=\"flex-1\"\n\t\t\t\t\t\tdata-testid=\"media-url-input\"\n\t\t\t\t\t\tautoFocus\n\t\t\t\t\t/>\n\t\t\t\t\t\n\t\t\t\t\t\t{isPending ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tUse URL\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{error &&

{error}

}\n\t\t\t
\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/media-picker/url-tab.tsx" + }, + { + "path": "btst/media/client/components/media-picker/utils.ts", + "type": "registry:lib", + "content": "export function matchesAccept(mimeType: string, accept?: string[]) {\n\tif (!accept || accept.length === 0) return true;\n\treturn accept.some((a) => {\n\t\tif (a.endsWith(\"/*\")) return mimeType.startsWith(a.slice(0, -1));\n\t\treturn mimeType === a;\n\t});\n}\n\nexport function isImage(mimeType: string) {\n\treturn mimeType.startsWith(\"image/\");\n}\n\nexport function formatBytes(bytes: number) {\n\tif (bytes < 1024) return `${bytes} B`;\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n\treturn `${(bytes / 1024 / 1024).toFixed(1)} MB`;\n}\n", + "target": "src/components/btst/media/client/components/media-picker/utils.ts" + }, + { + "path": "btst/media/client/components/pages/library-page.internal.tsx", + "type": "registry:component", + "content": "\"use client\";\nimport { useState, useCallback, useRef } from \"react\";\nimport { useDeleteAsset, useUploadAsset } from \"@btst/stack/plugins/media/client/hooks\";\nimport { Button } from \"@/components/ui/button\";\nimport { Upload, Loader2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { toast } from \"sonner\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { MediaPluginOverrides } from \"../../overrides\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { BrowseTab } from \"../media-picker/browse-tab\";\nimport { FolderTree } from \"../media-picker/folder-tree\";\n\nexport function LibraryPage() {\n\tconst overrides = usePluginOverrides<\n\t\tMediaPluginOverrides,\n\t\tPartial\n\t>(\"media\", {});\n\n\tuseRouteLifecycle({\n\t\trouteName: \"library\",\n\t\tcontext: {\n\t\t\tpath: \"/media\",\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforeLibraryPageRendered) {\n\t\t\t\treturn overrides.onBeforeLibraryPageRendered(context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst [selectedFolder, setSelectedFolder] = useState(null);\n\tconst [dragging, setDragging] = useState(false);\n\tconst fileInputRef = useRef(null);\n\tconst { mutateAsync: uploadAsset, isPending: isUploading } = useUploadAsset();\n\tconst { mutateAsync: deleteAsset } = useDeleteAsset();\n\tconst { apiBaseURL = \"\" } = overrides;\n\n\tconst handleUpload = useCallback(\n\t\tasync (files: FileList | File[]) => {\n\t\t\tconst arr = Array.from(files);\n\t\t\tfor (const file of arr) {\n\t\t\t\ttry {\n\t\t\t\t\tawait uploadAsset({ file, folderId: selectedFolder ?? undefined });\n\t\t\t\t\ttoast.success(`Uploaded ${file.name}`);\n\t\t\t\t} catch (err) {\n\t\t\t\t\ttoast.error(err instanceof Error ? err.message : \"Upload failed\");\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\t[selectedFolder, uploadAsset],\n\t);\n\n\tconst handleDelete = async (id: string) => {\n\t\tif (!confirm(\"Delete this asset?\")) return;\n\t\ttry {\n\t\t\tawait deleteAsset(id);\n\t\t\ttoast.success(\"Deleted\");\n\t\t} catch (err) {\n\t\t\ttoast.error(err instanceof Error ? err.message : \"Delete failed\");\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\n\t\t\t {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tsetDragging(true);\n\t\t\t\t}}\n\t\t\t\tonDragLeave={() => setDragging(false)}\n\t\t\t\tonDrop={(e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tsetDragging(false);\n\t\t\t\t\tvoid handleUpload(e.dataTransfer.files);\n\t\t\t\t}}\n\t\t\t>\n\t\t\t\t{/* Toolbar */}\n\t\t\t\t
\n\t\t\t\t\t fileInputRef.current?.click()}\n\t\t\t\t\t\tdisabled={isUploading}\n\t\t\t\t\t\tclassName=\"w-full sm:w-auto\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{isUploading ? (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tUpload\n\t\t\t\t\t\n\t\t\t\t\t e.target.files && handleUpload(e.target.files)}\n\t\t\t\t\t/>\n\t\t\t\t
\n\n\t\t\t\t{/* Drop overlay */}\n\t\t\t\t{dragging && (\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

Drop files to upload

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t
\n\t\t
\n\t);\n}\n", + "target": "src/components/btst/media/client/components/pages/library-page.internal.tsx" + }, + { + "path": "btst/media/client/components/pages/library-page.tsx", + "type": "registry:page", + "content": "\"use client\";\nimport { lazy } from \"react\";\nimport type { FallbackProps } from \"react-error-boundary\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport type { MediaPluginOverrides } from \"../../overrides\";\nimport { Loader2 } from \"lucide-react\";\n\nconst LibraryPage = lazy(() =>\n\timport(\"./library-page.internal\").then((m) => ({ default: m.LibraryPage })),\n);\n\nfunction LibraryLoading() {\n\treturn (\n\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n\nfunction LibraryError({ error }: FallbackProps) {\n\tconst message = error instanceof Error ? error.message : String(error);\n\treturn (\n\t\t
\n\t\t\t

{message}

\n\t\t
\n\t);\n}\n\nexport function LibraryPageComponent() {\n\tusePluginOverrides(\"media\");\n\treturn (\n\t\t null}\n\t\t\tonError={(error) => console.error(\"[btst/media] Library error:\", error)}\n\t\t/>\n\t);\n}\n", + "target": "src/components/btst/media/client/components/pages/library-page.tsx" + }, + { + "path": "btst/media/client/overrides.ts", + "type": "registry:lib", + "content": "import type { ComponentType } from \"react\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { ImageCompressionOptions } from \"./utils/image-compression\";\n\n/**\n * Upload mode — must match the storage adapter configured in mediaBackendPlugin.\n * - `\"direct\"` — local filesystem adapter, files are uploaded via `POST /media/upload`\n * - `\"s3\"` — AWS S3 / R2 / MinIO, the client fetches a presigned token then PUTs directly to S3\n * - `\"vercel-blob\"` — Vercel Blob, uses the `@vercel/blob/client` SDK for direct upload\n */\nexport type MediaUploadMode = \"direct\" | \"s3\" | \"vercel-blob\";\n\n/**\n * Overridable components and functions for the Media plugin.\n *\n * External consumers provide these when registering the media client plugin\n * via the StackProvider overrides.\n */\nexport interface MediaPluginOverrides {\n\t/**\n\t * Base URL for API calls (e.g., \"http://localhost:3000\").\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * Path where the API is mounted (e.g., \"/api/data\").\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * React Query client — used by the MediaPicker to cache and fetch assets.\n\t */\n\tqueryClient: QueryClient;\n\n\t/**\n\t * Upload mode — must match the storageAdapter configured in mediaBackendPlugin.\n\t * @default \"direct\"\n\t */\n\tuploadMode?: MediaUploadMode;\n\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth).\n\t */\n\theaders?: HeadersInit;\n\n\t/**\n\t * Navigation function for programmatic navigation.\n\t */\n\tnavigate: (path: string) => void | Promise;\n\n\t/**\n\t * Link component for navigation within the media library page.\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Image component for rendering asset thumbnails and previews.\n\t *\n\t * When provided, replaces the default `` element in asset cards,\n\t * the media library grid, and the ImageInputField preview. Use this\n\t * to plug in Next.js `` for automatic optimisation.\n\t *\n\t * @example\n\t * ```tsx\n\t * Image: (props) => \n\t * ```\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\n\t/**\n\t * Client-side image compression applied before upload via the Canvas API.\n\t *\n\t * Images are scaled down to fit within `maxWidth` × `maxHeight` (preserving\n\t * aspect ratio) and re-encoded at `quality`. SVG and GIF files are always\n\t * passed through unchanged.\n\t *\n\t * Set to `false` to disable compression entirely.\n\t *\n\t * @default { maxWidth: 2048, maxHeight: 2048, quality: 0.85 }\n\t */\n\timageCompression?: ImageCompressionOptions | false;\n\n\t// ============ Lifecycle Hooks ============\n\n\t/**\n\t * Called when a media route is rendered.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: MediaRouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a media route encounters an error.\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: MediaRouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the media library page is rendered.\n\t * Return `false` to prevent rendering (e.g., redirect unauthenticated users).\n\t *\n\t * @example\n\t * ```ts\n\t * media: {\n\t * onBeforeLibraryPageRendered: (context) => !!currentUser?.isAdmin,\n\t * onRouteError: (routeName, error, context) => navigate(\"/login\"),\n\t * }\n\t * ```\n\t */\n\tonBeforeLibraryPageRendered?: (context: MediaRouteContext) => boolean;\n}\n\nexport interface MediaRouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t[key: string]: unknown;\n}\n", + "target": "src/components/btst/media/client/overrides.ts" + }, + { + "path": "btst/media/client/upload.ts", + "type": "registry:lib", + "content": "\"use client\";\n\nimport type { SerializedAsset } from \"../types\";\nimport type { MediaPluginOverrides } from \"./overrides\";\nimport { compressImage } from \"./utils/image-compression\";\n\nexport type MediaUploadClientConfig = Pick<\n\tMediaPluginOverrides,\n\t\"apiBaseURL\" | \"apiBasePath\" | \"headers\" | \"uploadMode\" | \"imageCompression\"\n>;\n\nexport interface UploadAssetInput {\n\tfile: File;\n\tfolderId?: string;\n}\n\nconst DEFAULT_IMAGE_COMPRESSION = {\n\tmaxWidth: 2048,\n\tmaxHeight: 2048,\n\tquality: 0.85,\n} as const;\n\n/**\n * Upload an asset using the media plugin's configured storage mode.\n *\n * Use this in non-React contexts like editor `uploadImage` callbacks. React\n * components should usually prefer `useUploadAsset()`, which wraps this helper\n * and handles cache invalidation.\n */\nexport async function uploadAsset(\n\tconfig: MediaUploadClientConfig,\n\tinput: UploadAssetInput,\n): Promise {\n\tconst {\n\t\tapiBaseURL,\n\t\tapiBasePath,\n\t\theaders,\n\t\tuploadMode = \"direct\",\n\t\timageCompression,\n\t} = config;\n\tconst { file, folderId } = input;\n\n\tconst processedFile =\n\t\timageCompression === false\n\t\t\t? file\n\t\t\t: await compressImage(\n\t\t\t\t\tfile,\n\t\t\t\t\timageCompression ?? DEFAULT_IMAGE_COMPRESSION,\n\t\t\t\t);\n\n\tconst base = `${apiBaseURL}${apiBasePath}`;\n\tconst headersObj = new Headers(headers as HeadersInit | undefined);\n\n\tif (uploadMode === \"direct\") {\n\t\tconst formData = new FormData();\n\t\tformData.append(\"file\", processedFile);\n\t\tif (folderId) formData.append(\"folderId\", folderId);\n\n\t\tconst res = await fetch(`${base}/media/upload`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: headersObj,\n\t\t\tbody: formData,\n\t\t});\n\t\tif (!res.ok) {\n\t\t\tconst err = await res.json().catch(() => ({ message: res.statusText }));\n\t\t\tthrow new Error(err.message ?? \"Upload failed\");\n\t\t}\n\t\treturn res.json();\n\t}\n\n\tif (uploadMode === \"s3\") {\n\t\tconst tokenRes = await fetch(`${base}/media/upload/token`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t...Object.fromEntries(headersObj.entries()),\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t\tbody: JSON.stringify({\n\t\t\t\tfilename: processedFile.name,\n\t\t\t\tmimeType: processedFile.type,\n\t\t\t\tsize: processedFile.size,\n\t\t\t\tfolderId,\n\t\t\t}),\n\t\t});\n\t\tif (!tokenRes.ok) {\n\t\t\tconst err = await tokenRes\n\t\t\t\t.json()\n\t\t\t\t.catch(() => ({ message: tokenRes.statusText }));\n\t\t\tthrow new Error(err.message ?? \"Failed to get upload token\");\n\t\t}\n\n\t\tconst token = (await tokenRes.json()) as {\n\t\t\ttype: \"presigned-url\";\n\t\t\tpayload: {\n\t\t\t\tuploadUrl: string;\n\t\t\t\tpublicUrl: string;\n\t\t\t\tkey: string;\n\t\t\t\tmethod: \"PUT\";\n\t\t\t\theaders: Record;\n\t\t\t};\n\t\t};\n\n\t\tconst putRes = await fetch(token.payload.uploadUrl, {\n\t\t\tmethod: \"PUT\",\n\t\t\theaders: token.payload.headers,\n\t\t\tbody: processedFile,\n\t\t});\n\t\tif (!putRes.ok) throw new Error(\"Failed to upload to S3\");\n\n\t\tconst assetRes = await fetch(`${base}/media/assets`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t...Object.fromEntries(headersObj.entries()),\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t\tbody: JSON.stringify({\n\t\t\t\tfilename: processedFile.name,\n\t\t\t\toriginalName: file.name,\n\t\t\t\tmimeType: processedFile.type,\n\t\t\t\tsize: processedFile.size,\n\t\t\t\turl: token.payload.publicUrl,\n\t\t\t\tfolderId,\n\t\t\t}),\n\t\t});\n\t\tif (!assetRes.ok) {\n\t\t\tconst err = await assetRes\n\t\t\t\t.json()\n\t\t\t\t.catch(() => ({ message: assetRes.statusText }));\n\t\t\tthrow new Error(err.message ?? \"Failed to register asset\");\n\t\t}\n\t\treturn assetRes.json();\n\t}\n\n\tif (uploadMode === \"vercel-blob\") {\n\t\t// Dynamic import keeps @vercel/blob/client optional.\n\t\tconst { upload } = await import(\"@vercel/blob/client\");\n\t\tconst blob = await upload(processedFile.name, processedFile, {\n\t\t\taccess: \"public\",\n\t\t\thandleUploadUrl: `${base}/media/upload/vercel-blob`,\n\t\t\tclientPayload: JSON.stringify({\n\t\t\t\tmimeType: processedFile.type,\n\t\t\t\tsize: processedFile.size,\n\t\t\t}),\n\t\t});\n\n\t\tconst assetRes = await fetch(`${base}/media/assets`, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: {\n\t\t\t\t...Object.fromEntries(headersObj.entries()),\n\t\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t},\n\t\t\tbody: JSON.stringify({\n\t\t\t\tfilename: processedFile.name,\n\t\t\t\toriginalName: file.name,\n\t\t\t\tmimeType: processedFile.type,\n\t\t\t\tsize: processedFile.size,\n\t\t\t\turl: blob.url,\n\t\t\t\tfolderId,\n\t\t\t}),\n\t\t});\n\t\tif (!assetRes.ok) {\n\t\t\tconst err = await assetRes\n\t\t\t\t.json()\n\t\t\t\t.catch(() => ({ message: assetRes.statusText }));\n\t\t\tthrow new Error(err.message ?? \"Failed to register asset\");\n\t\t}\n\t\treturn assetRes.json();\n\t}\n\n\tthrow new Error(`Unknown uploadMode: ${uploadMode}`);\n}\n", + "target": "src/components/btst/media/client/upload.ts" + }, + { + "path": "btst/media/client/utils/image-compression.ts", + "type": "registry:lib", + "content": "/**\n * Canvas-based client-side image compression.\n *\n * Skips SVG and GIF (vector data / animation would be lost on a canvas round-trip).\n * All other image/* types are scaled down to fit within maxWidth × maxHeight\n * (preserving aspect ratio) and re-encoded at the configured quality.\n */\n\nexport interface ImageCompressionOptions {\n\t/**\n\t * Maximum width in pixels. Images wider than this are scaled down.\n\t * @default 2048\n\t */\n\tmaxWidth?: number;\n\n\t/**\n\t * Maximum height in pixels. Images taller than this are scaled down.\n\t * @default 2048\n\t */\n\tmaxHeight?: number;\n\n\t/**\n\t * Encoding quality (0–1). Applies to JPEG and WebP.\n\t * @default 0.85\n\t */\n\tquality?: number;\n\n\t/**\n\t * Output MIME type. Defaults to the source image's MIME type.\n\t * Set to `\"image/webp\"` for better compression at the cost of format change.\n\t */\n\toutputFormat?: string;\n}\n\nfunction loadImage(file: File): Promise {\n\treturn new Promise((resolve, reject) => {\n\t\tconst url = URL.createObjectURL(file);\n\t\tconst img = new Image();\n\t\timg.onload = () => {\n\t\t\tURL.revokeObjectURL(url);\n\t\t\tresolve(img);\n\t\t};\n\t\timg.onerror = () => {\n\t\t\tURL.revokeObjectURL(url);\n\t\t\treject(new Error(`Failed to load image: ${file.name}`));\n\t\t};\n\t\timg.src = url;\n\t});\n}\n\nconst SKIP_TYPES = new Set([\"image/svg+xml\", \"image/gif\"]);\n\n/**\n * Compresses an image file client-side using the Canvas API.\n *\n * Returns the original file unchanged if:\n * - The file is not an image\n * - The MIME type is SVG or GIF (would lose vector data / animation)\n * - The browser does not support canvas (SSR guard)\n */\nexport async function compressImage(\n\tfile: File,\n\toptions: ImageCompressionOptions = {},\n): Promise {\n\tif (!file.type.startsWith(\"image/\") || SKIP_TYPES.has(file.type)) {\n\t\treturn file;\n\t}\n\n\t// SSR guard — canvas is only available in the browser\n\tif (typeof document === \"undefined\") return file;\n\n\tconst {\n\t\tmaxWidth = 2048,\n\t\tmaxHeight = 2048,\n\t\tquality = 0.85,\n\t\toutputFormat,\n\t} = options;\n\n\tconst img = await loadImage(file);\n\n\tlet { width, height } = img;\n\n\tconst needsResize = width > maxWidth || height > maxHeight;\n\tconst needsFormatChange =\n\t\toutputFormat !== undefined && outputFormat !== file.type;\n\n\t// Skip canvas entirely if the image is already within the limits and no\n\t// format conversion is needed — re-encoding a small image can make it larger.\n\tif (!needsResize && !needsFormatChange) return file;\n\n\t// Scale down proportionally if either dimension exceeds the max\n\tif (needsResize) {\n\t\tconst ratio = Math.min(maxWidth / width, maxHeight / height);\n\t\twidth = Math.round(width * ratio);\n\t\theight = Math.round(height * ratio);\n\t}\n\n\tconst canvas = document.createElement(\"canvas\");\n\tcanvas.width = width;\n\tcanvas.height = height;\n\n\tconst ctx = canvas.getContext(\"2d\");\n\tif (!ctx) return file;\n\n\tctx.drawImage(img, 0, 0, width, height);\n\n\tconst mimeType = outputFormat ?? file.type;\n\n\treturn new Promise((resolve, reject) => {\n\t\tcanvas.toBlob(\n\t\t\t(blob) => {\n\t\t\t\tif (!blob) {\n\t\t\t\t\treject(new Error(\"canvas.toBlob returned null\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Preserve the original filename, updating extension only if\n\t\t\t\t// the output format changed from the source.\n\t\t\t\tlet name = file.name;\n\t\t\t\tif (outputFormat && outputFormat !== file.type) {\n\t\t\t\t\tconst ext = outputFormat.split(\"/\")[1] ?? \"jpg\";\n\t\t\t\t\tname = name.replace(/\\.[^.]+$/, `.${ext}`);\n\t\t\t\t}\n\n\t\t\t\tresolve(new File([blob], name, { type: mimeType }));\n\t\t\t},\n\t\t\tmimeType,\n\t\t\tquality,\n\t\t);\n\t});\n}\n", + "target": "src/components/btst/media/client/utils/image-compression.ts" + }, + { + "path": "ui/hooks/use-route-lifecycle.ts", + "type": "registry:hook", + "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\n/**\n * Base route context interface that plugins can extend\n */\nexport interface BaseRouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { slug: \"my-post\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Minimum interface required for route lifecycle hooks\n * Plugin overrides should implement these optional hooks\n */\nexport interface RouteLifecycleOverrides {\n\t/** Called when a route is rendered */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: TContext,\n\t) => void | Promise;\n\t/** Called when a route encounters an error */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: TContext,\n\t) => void | Promise;\n}\n\n/**\n * Hook to handle route lifecycle events\n * - Calls authorization check before render\n * - Calls onRouteRender on mount\n * - Handles errors with onRouteError\n *\n * @example\n * ```tsx\n * const overrides = usePluginOverrides(\"myPlugin\");\n *\n * useRouteLifecycle({\n * routeName: \"dashboard\",\n * context: { path: \"/dashboard\", isSSR: typeof window === \"undefined\" },\n * overrides,\n * beforeRenderHook: (overrides, context) => {\n * if (overrides.onBeforeDashboardRendered) {\n * return overrides.onBeforeDashboardRendered(context);\n * }\n * return true;\n * },\n * });\n * ```\n */\nexport function useRouteLifecycle<\n\tTContext extends BaseRouteContext,\n\tTOverrides extends RouteLifecycleOverrides,\n>({\n\trouteName,\n\tcontext,\n\toverrides,\n\tbeforeRenderHook,\n}: {\n\trouteName: string;\n\tcontext: TContext;\n\toverrides: TOverrides;\n\tbeforeRenderHook?: (overrides: TOverrides, context: TContext) => boolean;\n}) {\n\t// Authorization check - runs synchronously before render\n\tif (beforeRenderHook) {\n\t\tconst canRender = beforeRenderHook(overrides, context);\n\t\tif (!canRender) {\n\t\t\tconst error = new Error(`Unauthorized: Cannot render ${routeName}`);\n\t\t\t// Call error hook synchronously\n\t\t\tif (overrides.onRouteError) {\n\t\t\t\ttry {\n\t\t\t\t\tconst result = overrides.onRouteError(routeName, error, context);\n\t\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\t\tresult.catch(() => {}); // Ignore promise rejection\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// Ignore errors in error hook\n\t\t\t\t}\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t// Lifecycle hook - runs on mount\n\tuseEffect(() => {\n\t\tif (overrides.onRouteRender) {\n\t\t\ttry {\n\t\t\t\tconst result = overrides.onRouteRender(routeName, context);\n\t\t\t\tif (result instanceof Promise) {\n\t\t\t\t\tresult.catch((error) => {\n\t\t\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\t\t\toverrides.onRouteError(routeName, error, context);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\t// If onRouteRender throws, call onRouteError\n\t\t\t\tif (overrides.onRouteError) {\n\t\t\t\t\toverrides.onRouteError(routeName, error as Error, context);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}, [routeName, overrides, context]);\n}\n", + "target": "src/hooks/use-route-lifecycle.ts" + } + ], + "docs": "https://better-stack.ai/docs/plugins/media" +} diff --git a/packages/stack/registry/registry.json b/packages/stack/registry/registry.json index fcc8f8d1..f02b772b 100644 --- a/packages/stack/registry/registry.json +++ b/packages/stack/registry/registry.json @@ -159,7 +159,7 @@ "card", "dialog", "dropdown-menu", - "https://raw.githubusercontent.com/olliethedev/shadcn-minimal-tiptap/refs/heads/feat/markdown/registry/block-registry.json", + "https://raw.githubusercontent.com/olliethedev/shadcn-minimal-tiptap/refs/heads/feat/image-upload-config/registry/block-registry.json", "input", "label", "select", @@ -219,6 +219,25 @@ "table" ], "docs": "https://better-stack.ai/docs/plugins/ui-builder" + }, + { + "name": "btst-media", + "type": "registry:block", + "title": "Media Plugin Pages", + "description": "Ejectable page components for the @btst/stack media plugin. Customize the UI layer while keeping data-fetching in @btst/stack.", + "author": "BTST ", + "dependencies": [ + "@btst/stack", + "@vercel/blob" + ], + "registryDependencies": [ + "button", + "dialog", + "input", + "popover", + "tabs" + ], + "docs": "https://better-stack.ai/docs/plugins/media" } ] } diff --git a/packages/stack/scripts/build-registry.ts b/packages/stack/scripts/build-registry.ts index b34f3a5b..3926013c 100644 --- a/packages/stack/scripts/build-registry.ts +++ b/packages/stack/scripts/build-registry.ts @@ -57,7 +57,7 @@ const EXTERNAL_REGISTRY_COMPONENTS: Record = { "form-builder": "https://raw.githubusercontent.com/better-stack-ai/form-builder/refs/heads/main/registry/form-builder.json", "minimal-tiptap": - "https://raw.githubusercontent.com/olliethedev/shadcn-minimal-tiptap/refs/heads/feat/markdown/registry/block-registry.json", + "https://raw.githubusercontent.com/olliethedev/shadcn-minimal-tiptap/refs/heads/feat/image-upload-config/registry/block-registry.json", "ui-builder": "https://raw.githubusercontent.com/olliethedev/ui-builder/refs/heads/main/registry/block-registry.json", }; @@ -297,6 +297,19 @@ const PLUGINS: PluginConfig[] = [ // hook files (excluded). Only types.ts is needed by ejected components. pluginRootFiles: ["types.ts"], }, + { + name: "media", + title: "Media Plugin Pages", + description: + "Ejectable page components for the @btst/stack media plugin. " + + "Customize the UI layer while keeping data-fetching in @btst/stack.", + // @vercel/blob is required by @btst/stack's use-media hook even when using + // "direct" upload mode — Turbopack statically resolves dynamic imports so + // the package must be present at build time. + extraNpmDeps: ["@vercel/blob"], + extraRegistryDeps: [], + pluginRootFiles: ["types.ts", "schemas.ts"], + }, ]; // --------------------------------------------------------------------------- diff --git a/packages/stack/scripts/test-registry.sh b/packages/stack/scripts/test-registry.sh index 474ac406..359b8fe3 100755 --- a/packages/stack/scripts/test-registry.sh +++ b/packages/stack/scripts/test-registry.sh @@ -47,7 +47,7 @@ SERVER_PORT=8766 SERVER_PID="" TEST_PASSED=false -PLUGIN_NAMES=("blog" "ai-chat" "cms" "form-builder" "kanban" "comments" "ui-builder") +PLUGIN_NAMES=("ui-builder" "blog" "ai-chat" "cms" "form-builder" "kanban" "comments" "media") # --------------------------------------------------------------------------- # Cleanup @@ -121,17 +121,20 @@ main() { npx --yes http-server "$REGISTRY_DIR" -p $SERVER_PORT -c-1 --silent & SERVER_PID=$! - # Wait for server to be ready (up to 15s), then an extra 20s for stability - for i in $(seq 1 15); do - if curl -sf "http://localhost:$SERVER_PORT/btst-blog.json" > /dev/null 2>&1; then + # Wait for server to be ready. `npx http-server` can take >15s on a cold cache + # or slow CI; use 127.0.0.1 to avoid IPv6 localhost ordering quirks. + SERVER_READY=false + for _ in $(seq 1 60); do + if curl -sf "http://127.0.0.1:$SERVER_PORT/btst-blog.json" > /dev/null 2>&1; then + SERVER_READY=true break fi sleep 1 - if [ "$i" = "15" ]; then - error "HTTP server did not become available in time" - exit 1 - fi done + if [ "$SERVER_READY" != true ]; then + error "HTTP server did not become available in time (waited 60s; check npx / port $SERVER_PORT)" + exit 1 + fi success "HTTP server running (PID: $SERVER_PID)" pause 20 @@ -333,10 +336,12 @@ import { FormListPageComponent } from "@/components/btst/form-builder/client/com import { BoardsListPageComponent } from "@/components/btst/kanban/client/components/pages/boards-list-page"; import { ModerationPageComponent } from "@/components/btst/comments/client/components/pages/moderation-page"; import { PageListPage } from "@/components/btst/ui-builder/client/components/pages/page-list-page"; +import { LibraryPageComponent } from "@/components/btst/media/client/components/pages/library-page"; // Suppress unused-import warnings while still forcing TS to resolve everything. void [HomePageComponent, ChatPageComponent, DashboardPageComponent, - FormListPageComponent, BoardsListPageComponent, ModerationPageComponent, PageListPage]; + FormListPageComponent, BoardsListPageComponent, ModerationPageComponent, PageListPage, + LibraryPageComponent]; export default function SmokeTestPage() { return
Registry smoke test — all plugin imports resolved.
; diff --git a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx index b3886714..8c553df8 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx @@ -28,13 +28,44 @@ export function FeaturedImageField({ const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); - const { uploadImage, Image, localization } = usePluginOverrides< - BlogPluginOverrides, - Partial - >("blog", { localization: BLOG_LOCALIZATION }); + const { + uploadImage, + Image, + localization, + imageInputField: ImageInput, + } = usePluginOverrides>( + "blog", + { localization: BLOG_LOCALIZATION }, + ); const ImageComponent = Image ? Image : DefaultImage; + // When a custom imageInput component is provided via overrides, delegate to it. + if (ImageInput) { + return ( + + + {localization.BLOG_FORMS_FEATURED_IMAGE_LABEL} + {isRequired && ( + + {" "} + {localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK} + + )} + + + + + + + + ); + } + const handleImageUpload = async ( event: React.ChangeEvent, ) => { diff --git a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx index 385ac9e1..e30eec54 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx @@ -1,4 +1,5 @@ "use client"; +import { useCallback, useRef } from "react"; import { usePluginOverrides } from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_LOCALIZATION } from "../../localization"; @@ -6,24 +7,78 @@ import { MarkdownEditor, type MarkdownEditorProps } from "./markdown-editor"; type MarkdownEditorWithOverridesProps = Omit< MarkdownEditorProps, - "uploadImage" | "placeholder" + | "uploadImage" + | "placeholder" + | "insertImageRef" + | "openMediaPickerForImageBlock" >; export function MarkdownEditorWithOverrides( props: MarkdownEditorWithOverridesProps, ) { - const { uploadImage, localization } = usePluginOverrides< - BlogPluginOverrides, - Partial - >("blog", { - localization: BLOG_LOCALIZATION, - }); + const { + uploadImage, + imagePicker: ImagePickerTrigger, + localization, + } = usePluginOverrides>( + "blog", + { localization: BLOG_LOCALIZATION }, + ); + + const insertImageRef = useRef<((url: string) => void) | null>(null); + // Holds the Crepe-image-block `setUrl` callback while the picker is open. + const pendingInsertUrlRef = useRef<((url: string) => void) | null>(null); + // Ref to the trigger wrapper so we can programmatically click the picker button. + const triggerContainerRef = useRef(null); + + // Single onSelect handler for ImagePickerTrigger. + // URLs returned by the media plugin are already percent-encoded at the + // source (storage adapter), so no additional encoding is applied here. + const handleSelect = useCallback((url: string) => { + if (pendingInsertUrlRef.current) { + // Crepe image block flow: set the URL into the block's link input. + pendingInsertUrlRef.current(url); + pendingInsertUrlRef.current = null; + } else { + // Normal flow: insert image at end of markdown content. + insertImageRef.current?.(url); + } + }, []); + + // Called by MarkdownEditor's click interceptor when the user clicks a Crepe + // image-block upload placeholder. + const openMediaPickerForImageBlock = useCallback( + (setUrl: (url: string) => void) => { + pendingInsertUrlRef.current = setUrl; + // Programmatically click the visible picker trigger button. + const btn = triggerContainerRef.current?.querySelector( + '[data-testid="open-media-picker"]', + ) as HTMLButtonElement | null; + btn?.click(); + }, + [], + ); return ( - +
+ + {ImagePickerTrigger && ( +
+ +
+ )} +
); } diff --git a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx index 1cebce5e..92c2c713 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.tsx @@ -8,7 +8,12 @@ import { editorViewCtx, parserCtx } from "@milkdown/kit/core"; import { listener, listenerCtx } from "@milkdown/kit/plugin/listener"; import { Slice } from "@milkdown/kit/prose/model"; import { Selection } from "@milkdown/kit/prose/state"; -import { useLayoutEffect, useRef, useState } from "react"; +import { + useLayoutEffect, + useRef, + useState, + type MutableRefObject, +} from "react"; export interface MarkdownEditorProps { value?: string; @@ -18,6 +23,19 @@ export interface MarkdownEditorProps { uploadImage?: (file: File) => Promise; /** Placeholder text shown when the editor is empty. */ placeholder?: string; + /** + * Optional ref that will be populated with an `insertImage(url)` function. + * Call `insertImageRef.current?.(url)` to programmatically insert an image. + * The URL must be a valid, percent-encoded URL (storage adapters guarantee this). + */ + insertImageRef?: MutableRefObject<((url: string) => void) | null>; + /** + * When provided, clicking the Crepe image block's upload area opens a media + * picker instead of the native file dialog. The callback receives a `setUrl` + * function — call it with the chosen URL to set it into the image block. + * The URL must be a valid, percent-encoded URL (storage adapters guarantee this). + */ + openMediaPickerForImageBlock?: (setUrl: (url: string) => void) => void; } export function MarkdownEditor({ @@ -26,6 +44,8 @@ export function MarkdownEditor({ className, uploadImage, placeholder = "Write something...", + insertImageRef, + openMediaPickerForImageBlock, }: MarkdownEditorProps) { const containerRef = useRef(null); const crepeRef = useRef(null); @@ -33,6 +53,9 @@ export function MarkdownEditor({ const [isReady, setIsReady] = useState(false); const onChangeRef = useRef(onChange); const initialValueRef = useRef(value ?? ""); + const openMediaPickerRef = useRef( + openMediaPickerForImageBlock, + ); type ThrottledFn = ((markdown: string) => void) & { cancel?: () => void; flush?: () => void; @@ -40,12 +63,24 @@ export function MarkdownEditor({ const throttledOnChangeRef = useRef(null); onChangeRef.current = onChange; + openMediaPickerRef.current = openMediaPickerForImageBlock; useLayoutEffect(() => { if (crepeRef.current) return; const container = containerRef.current; if (!container) return; + const hasMediaPicker = !!openMediaPickerRef.current; + + const imageBlockConfig: Record = {}; + if (uploadImage) { + imageBlockConfig.onUpload = async (file: File) => uploadImage(file); + } + if (hasMediaPicker) { + imageBlockConfig.blockUploadPlaceholderText = "Media Picker"; + imageBlockConfig.inlineUploadPlaceholderText = "Media Picker"; + } + const crepe = new Crepe({ root: container, defaultValue: initialValueRef.current, @@ -53,19 +88,47 @@ export function MarkdownEditor({ [CrepeFeature.Placeholder]: { text: placeholder, }, - ...(uploadImage - ? { - [CrepeFeature.ImageBlock]: { - onUpload: async (file: File) => { - const url = await uploadImage(file); - return url; - }, - }, - } + ...(Object.keys(imageBlockConfig).length > 0 + ? { [CrepeFeature.ImageBlock]: imageBlockConfig } : {}), }, }); + // Intercept clicks on Crepe image-block upload placeholders so that the + // native file dialog is suppressed and the media picker is opened instead. + const interceptHandler = (e: MouseEvent) => { + if (!openMediaPickerRef.current) return; + const target = e.target as Element; + // Only intercept clicks inside the upload placeholder area. + const inPlaceholder = target.closest(".image-edit .placeholder"); + if (!inPlaceholder) return; + // Let the hidden file itself through (shouldn't receive clicks normally). + if ((target as HTMLElement).matches("input")) return; + + e.preventDefault(); + e.stopPropagation(); + + const imageEdit = inPlaceholder.closest(".image-edit"); + const linkInput = imageEdit?.querySelector( + ".link-input-area", + ) as HTMLInputElement | null; + + openMediaPickerRef.current((url: string) => { + if (!linkInput) return; + // Use the native setter so Vue's reactivity picks up the change. + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + nativeSetter?.call(linkInput, url); + linkInput.dispatchEvent(new Event("input", { bubbles: true })); + linkInput.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + }); + }; + container.addEventListener("click", interceptHandler, true); + // Prepare throttled onChange once per editor instance throttledOnChangeRef.current = throttle((markdown: string) => { if (onChangeRef.current) onChangeRef.current(markdown); @@ -86,6 +149,7 @@ export function MarkdownEditor({ crepeRef.current = crepe; return () => { + container.removeEventListener("click", interceptHandler, true); try { isReadyRef.current = false; throttledOnChangeRef.current?.cancel?.(); @@ -133,6 +197,38 @@ export function MarkdownEditor({ }); }, [value, isReady]); + // Expose insertImage via ref so the parent can insert images programmatically + useLayoutEffect(() => { + if (!insertImageRef) return; + insertImageRef.current = (url: string) => { + if (!crepeRef.current || !isReadyRef.current) return; + try { + const currentMarkdown = crepeRef.current.getMarkdown?.() ?? ""; + const imageMarkdown = `\n\n![](${url})\n\n`; + const newMarkdown = currentMarkdown.trimEnd() + imageMarkdown; + crepeRef.current.editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const parser = ctx.get(parserCtx); + const doc = parser(newMarkdown); + if (!doc) return; + const state = view.state; + const tr = state.tr.replace( + 0, + state.doc.content.size, + new Slice(doc.content, 0, 0), + ); + view.dispatch(tr); + }); + if (onChangeRef.current) onChangeRef.current(newMarkdown); + } catch { + // Editor may not be ready yet + } + }; + return () => { + if (insertImageRef) insertImageRef.current = null; + }; + }, [insertImageRef]); + return (
); diff --git a/packages/stack/src/plugins/blog/client/overrides.ts b/packages/stack/src/plugins/blog/client/overrides.ts index c1d543ed..974233ea 100644 --- a/packages/stack/src/plugins/blog/client/overrides.ts +++ b/packages/stack/src/plugins/blog/client/overrides.ts @@ -2,6 +2,18 @@ import type { SerializedPost } from "../types"; import type { ComponentType, ReactNode } from "react"; import type { BlogLocalization } from "./localization"; +/** + * Props for the overridable blog featured image input component. + */ +export interface BlogImageInputFieldProps { + /** Current image URL value */ + value: string; + /** Called when the image URL changes */ + onChange: (value: string) => void; + /** Whether the field is required */ + isRequired?: boolean; +} + /** * Context passed to lifecycle hooks */ @@ -48,9 +60,54 @@ export interface BlogPluginOverrides { React.ImgHTMLAttributes & Record >; /** - * Function used to upload an image and return its URL. + * Function used to upload a new image file and return its URL. + * This is separate from `imagePicker`, which selects an existing asset URL. */ uploadImage: (file: File) => Promise; + /** + * Optional custom component for the featured image field. + * + * When provided it replaces the default file-upload input entirely. + * The component receives `value` (current URL string) and `onChange` (setter). + * + * Typical use case: render a preview when a value is set, and a media-picker + * trigger when no value is set. + * + * @example + * ```tsx + * imageInputField: ({ value, onChange }) => + * value ? ( + *
+ * Preview + * Change} accept={["image/*"]} + * onSelect={(assets) => onChange(assets[0].url)} /> + *
+ * ) : ( + * Browse media} accept={["image/*"]} + * onSelect={(assets) => onChange(assets[0].url)} /> + * ) + * ``` + */ + imageInputField?: ComponentType; + + /** + * Optional trigger component for a media picker. + * When provided, it is rendered adjacent to the Markdown editor and allows + * users to browse and select previously uploaded assets. + * Receives `onSelect(url)` — insert the chosen URL into the editor. + * + * @example + * ```tsx + * imagePicker: ({ onSelect }) => ( + * Browse media} + * accept={["image/*"]} + * onSelect={(assets) => onSelect(assets[0].url)} + * /> + * ) + * ``` + */ + imagePicker?: ComponentType<{ onSelect: (url: string) => void }>; /** * Localization object for the blog plugin */ diff --git a/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx b/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx index 158cee9a..de59c6a5 100644 --- a/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx +++ b/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx @@ -56,6 +56,12 @@ function buildFieldConfigFromJsonSchema( string, React.ComponentType >, + imagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>, + imageInputField?: React.ComponentType<{ + value: string; + onChange: (value: string) => void; + isRequired?: boolean; + }>, ): FieldConfig> { // Get base config from shared utility (handles fieldType from JSON Schema) const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents); @@ -73,14 +79,14 @@ function buildFieldConfigFromJsonSchema( // Handle "file" fieldType when there's NO custom component for "file" if (prop.fieldType === "file" && !fieldComponents?.["file"]) { // Use CMSFileUpload as the default file component - if (!uploadImage) { - // Show a clear error message if uploadImage is not provided + if (!uploadImage && !imageInputField) { + // Show a clear error message if neither uploadImage nor imageInputField is provided baseConfig[key] = { ...baseConfig[key], fieldType: () => (
- File upload requires an uploadImage function in CMS - overrides. + File upload requires an uploadImage or{" "} + imageInputField function in CMS overrides.
), }; @@ -88,7 +94,12 @@ function buildFieldConfigFromJsonSchema( baseConfig[key] = { ...baseConfig[key], fieldType: (props: AutoFormInputComponentProps) => ( - + Promise.resolve(""))} + imageInputField={imageInputField} + imagePicker={imagePicker} + /> ), }; } @@ -151,6 +162,8 @@ export function ContentForm({ const { localization: customLocalization, uploadImage, + imagePicker, + imageInputField, fieldComponents, } = usePluginOverrides("cms"); const localization = { ...CMS_LOCALIZATION, ...customLocalization }; @@ -214,8 +227,14 @@ export function ContentForm({ // Build field config for AutoForm (fieldType is now embedded in jsonSchema) const fieldConfig = useMemo( () => - buildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents), - [jsonSchema, uploadImage, fieldComponents], + buildFieldConfigFromJsonSchema( + jsonSchema, + uploadImage, + fieldComponents, + imagePicker, + imageInputField, + ), + [jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField], ); // Find the field to use for slug auto-generation diff --git a/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx b/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx index 54f8b973..b76f7a68 100644 --- a/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx +++ b/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx @@ -1,6 +1,12 @@ "use client"; -import { useState, useCallback, useEffect, type ChangeEvent } from "react"; +import { + useState, + useCallback, + useEffect, + type ChangeEvent, + type ComponentType, +} from "react"; import { toast } from "sonner"; import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types"; import { Input } from "@workspace/ui/components/input"; @@ -23,6 +29,20 @@ export interface CMSFileUploadProps extends AutoFormInputComponentProps { * This is required - consumers must provide an upload implementation. */ uploadImage: (file: File) => Promise; + /** + * Optional custom component for the image field. + * When provided, it replaces the default file-upload input entirely. + */ + imageInputField?: ComponentType<{ + value: string; + onChange: (value: string) => void; + isRequired?: boolean; + }>; + /** + * Optional trigger component for a media picker. + * When provided, it is rendered as a "Browse media" option. + */ + imagePicker?: ComponentType<{ onSelect: (url: string) => void }>; } /** @@ -54,6 +74,8 @@ export function CMSFileUpload({ fieldProps, field, uploadImage, + imageInputField: ImageInputField, + imagePicker: ImagePickerTrigger, }: CMSFileUploadProps) { // Exclude showLabel and value from props spread // File inputs cannot have their value set programmatically (browser security) @@ -63,6 +85,8 @@ export function CMSFileUpload({ ...safeFieldProps } = fieldProps; const showLabel = _showLabel === undefined ? true : _showLabel; + + // All hooks must be called unconditionally before any early return. const [isUploading, setIsUploading] = useState(false); const [previewUrl, setPreviewUrl] = useState( field.value || null, @@ -80,7 +104,6 @@ export function CMSFileUpload({ const file = e.target.files?.[0]; if (!file) return; - // Check if it's an image if (!file.type.startsWith("image/")) { toast.error("Please select an image file"); return; @@ -106,6 +129,29 @@ export function CMSFileUpload({ field.onChange(""); }, [field]); + // When a custom imageInputField component is provided via overrides, delegate to it. + if (ImageInputField) { + return ( + + {showLabel && ( + + )} + + + + + + + ); + } + return ( {showLabel && ( @@ -116,19 +162,31 @@ export function CMSFileUpload({ )} {!previewUrl && ( -
- - {isUploading && ( -
- +
+
+ + {isUploading && ( +
+ +
+ )} +
+ {ImagePickerTrigger && ( +
+ { + setPreviewUrl(url); + field.onChange(url); + }} + />
)}
diff --git a/packages/stack/src/plugins/cms/client/overrides.ts b/packages/stack/src/plugins/cms/client/overrides.ts index e65d9dc6..6b1b1e49 100644 --- a/packages/stack/src/plugins/cms/client/overrides.ts +++ b/packages/stack/src/plugins/cms/client/overrides.ts @@ -2,6 +2,18 @@ import type { ComponentType } from "react"; import type { CMSLocalization } from "./localization"; import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types"; +/** + * Props for the overridable CMS image input field component. + */ +export interface CmsImageInputFieldProps { + /** Current image URL value */ + value: string; + /** Called when the image URL changes */ + onChange: (value: string) => void; + /** Whether the field is required */ + isRequired?: boolean; +} + /** * Context passed to lifecycle hooks */ @@ -46,11 +58,54 @@ export interface CMSPluginOverrides { >; /** - * Function used to upload an image and return its URL. - * Used by the default "file" field component. + * Function used to upload a new image file and return its URL. + * Used by the default "file" field component when not selecting an existing + * asset via `imagePicker` or `imageInputField`. */ uploadImage?: (file: File) => Promise; + /** + * Optional custom component for image fields (fieldType: "file"). + * + * When provided it replaces the default file-upload input entirely. + * The component receives `value` (current URL string) and `onChange` (setter). + * + * @example + * ```tsx + * imageInputField: ({ value, onChange }) => + * value ? ( + *
+ * Preview + * Change} accept={["image/*"]} + * onSelect={(assets) => onChange(assets[0].url)} /> + *
+ * ) : ( + * Browse media} accept={["image/*"]} + * onSelect={(assets) => onChange(assets[0].url)} /> + * ) + * ``` + */ + imageInputField?: ComponentType; + + /** + * Optional trigger component for a media picker. + * When provided, it is rendered inside the default "file" field component as a + * "Browse media" option, letting users select a previously uploaded asset. + * Receives `onSelect(url)` — the URL is set as the field value. + * + * @example + * ```tsx + * imagePicker: ({ onSelect }) => ( + * Browse media} + * accept={["image/*"]} + * onSelect={(assets) => onSelect(assets[0].url)} + * /> + * ) + * ``` + */ + imagePicker?: ComponentType<{ onSelect: (url: string) => void }>; + /** * Custom field components for AutoForm fields. * diff --git a/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx b/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx index b2c2ed83..1105d3bc 100644 --- a/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx +++ b/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx @@ -50,7 +50,7 @@ export function BoardForm({ board, onClose, onSuccess }: BoardFormProps) { }; return ( -
+
("kanban"); const { createTask, updateTask, @@ -155,7 +159,7 @@ export function TaskForm({ }; return ( - +
diff --git a/packages/stack/src/plugins/kanban/client/overrides.ts b/packages/stack/src/plugins/kanban/client/overrides.ts index 9c90cc33..bb6385e9 100644 --- a/packages/stack/src/plugins/kanban/client/overrides.ts +++ b/packages/stack/src/plugins/kanban/client/overrides.ts @@ -73,6 +73,31 @@ export interface KanbanPluginOverrides { */ headers?: HeadersInit; + /** + * Function used to upload a new image file from the task description editor + * and return its URL. This is separate from `imagePicker`, which selects an + * existing asset URL. + */ + uploadImage?: (file: File) => Promise; + + /** + * Optional trigger component for a media picker. + * When provided, it appears inside the image insertion dialog of the task description editor, + * letting users browse and select previously uploaded assets. + * + * @example + * ```tsx + * imagePicker: ({ onSelect }) => ( + * Browse media} + * accept={["image/*"]} + * onSelect={(assets) => onSelect(assets[0].url)} + * /> + * ) + * ``` + */ + imagePicker?: ComponentType<{ onSelect: (url: string) => void }>; + // ============ User Resolution (required for assignee features) ============ /** diff --git a/packages/stack/src/plugins/media/__tests__/plugin.test.ts b/packages/stack/src/plugins/media/__tests__/plugin.test.ts index 10b59021..5a4eb3d1 100644 --- a/packages/stack/src/plugins/media/__tests__/plugin.test.ts +++ b/packages/stack/src/plugins/media/__tests__/plugin.test.ts @@ -215,17 +215,34 @@ async function createAssetViaApi( }; } -function invokeEndpoint( +async function parseRequestBody( + request: Request, +): Promise | undefined> { + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("multipart/form-data")) { + const formData = await request.formData(); + const body: Record = {}; + formData.forEach((value, key) => { + body[key] = value; + }); + return body; + } + return undefined; +} + +async function invokeEndpoint( backend: ReturnType, endpointKey: string, request: Request, ) { + const body = await parseRequestBody(request); return (backend.router as any).endpoints[endpointKey]({ request, headers: request.headers, method: request.method, params: {}, query: {}, + body, asResponse: true, }); } diff --git a/packages/stack/src/plugins/media/api/adapters/local.ts b/packages/stack/src/plugins/media/api/adapters/local.ts index 23714158..e098776d 100644 --- a/packages/stack/src/plugins/media/api/adapters/local.ts +++ b/packages/stack/src/plugins/media/api/adapters/local.ts @@ -51,13 +51,20 @@ export function localAdapter( await fs.writeFile(filePath, buffer); - const url = `${publicPath.replace(/\/$/, "")}/${storedFilename}`; + // Percent-encode the filename segment so the returned URL is always a + // valid URL — e.g. spaces become %20. The raw storedFilename is used for + // the filesystem path; the encoded form is what gets stored in the DB and + // served to clients. + const url = `${publicPath.replace(/\/$/, "")}/${encodeURIComponent(storedFilename)}`; return { url }; }, async delete(url: string): Promise { - const filename = url.split("/").pop(); - if (!filename) return; + // The stored URL has an encoded filename (e.g. "my%20file.png"); decode + // it back to the raw filesystem name before building the file path. + const encodedFilename = url.split("/").pop(); + if (!encodedFilename) return; + const filename = decodeURIComponent(encodedFilename); const filePath = path.join(uploadDir, filename); try { diff --git a/packages/stack/src/plugins/media/api/adapters/s3.ts b/packages/stack/src/plugins/media/api/adapters/s3.ts index 4b3b2257..ec17f9a5 100644 --- a/packages/stack/src/plugins/media/api/adapters/s3.ts +++ b/packages/stack/src/plugins/media/api/adapters/s3.ts @@ -159,7 +159,13 @@ export function s3Adapter(options: S3StorageAdapterOptions): S3StorageAdapter { }); const uploadUrl = await buildSignedUrl(client, command, { expiresIn }); - const publicUrl = `${publicBaseUrl.replace(/\/$/, "")}/${key}`; + + // Percent-encode each path segment so the stored public URL is always + // valid. The raw `key` is used for the S3 key (which the AWS SDK + // handles separately); the encoded form is what gets stored in the DB + // and returned to clients. + const encodedKey = key.split("/").map(encodeURIComponent).join("/"); + const publicUrl = `${publicBaseUrl.replace(/\/$/, "")}/${encodedKey}`; return { type: "presigned-url", @@ -180,9 +186,11 @@ export function s3Adapter(options: S3StorageAdapterOptions): S3StorageAdapter { ]); const base = publicBaseUrl.replace(/\/$/, ""); - const key = url.startsWith(base) + const encodedKey = url.startsWith(base) ? url.slice(base.length + 1) : (url.split("/").pop() ?? url); + // Decode the percent-encoded key back to the raw S3 object key. + const key = decodeURIComponent(encodedKey); await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); }, diff --git a/packages/stack/src/plugins/media/api/index.ts b/packages/stack/src/plugins/media/api/index.ts index 603edc11..789fca25 100644 --- a/packages/stack/src/plugins/media/api/index.ts +++ b/packages/stack/src/plugins/media/api/index.ts @@ -30,16 +30,6 @@ export { type LocalStorageAdapterOptions, } from "./adapters/local"; -export { - s3Adapter, - type S3StorageAdapterOptions, -} from "./adapters/s3"; - -export { - vercelBlobAdapter, - type VercelBlobStorageAdapterOptions, -} from "./adapters/vercel-blob"; - export type { StorageAdapter, DirectStorageAdapter, diff --git a/packages/stack/src/plugins/media/api/plugin.ts b/packages/stack/src/plugins/media/api/plugin.ts index 220e3f5d..29471015 100644 --- a/packages/stack/src/plugins/media/api/plugin.ts +++ b/packages/stack/src/plugins/media/api/plugin.ts @@ -555,6 +555,13 @@ export const mediaBackendPlugin = (config: MediaBackendConfig) => "/media/upload", { method: "POST", + metadata: { + // Tell Better Call this endpoint accepts multipart/form-data so it + // parses the body into a FormData object and exposes it as ctx.body. + // Without this, Better Call may pre-read the body stream and calling + // ctx.request.formData() afterwards fails with "Body already read". + allowedMediaTypes: ["multipart/form-data"], + }, }, async (ctx) => { if (!isDirectAdapter(storageAdapter)) { @@ -564,21 +571,58 @@ export const mediaBackendPlugin = (config: MediaBackendConfig) => }); } - if (!ctx.request) { + // Better Call parses multipart/form-data into a plain object on ctx.body, + // where each field's value is preserved as-is (File instances for file fields). + const body = ctx.body as Record | undefined; + + if (!body || typeof body !== "object") { throw ctx.error(400, { - message: "Request object is not available", + message: "Expected multipart/form-data request body", }); } - const formData = await ctx.request.formData(); - const file = formData.get("file"); + const fileRaw = body.file; - if (!file || !(file instanceof File)) { + // Use a duck-type check instead of instanceof File to avoid + // cross-module-boundary failures (e.g. undici's File vs globalThis.File). + if ( + !fileRaw || + typeof fileRaw !== "object" || + typeof (fileRaw as any).arrayBuffer !== "function" + ) { throw ctx.error(400, { message: "Missing 'file' field in form data", }); } + if ( + typeof (fileRaw as any).size !== "number" || + (fileRaw as any).size < 0 + ) { + throw ctx.error(400, { + message: "File 'size' is missing or invalid", + }); + } + if ( + typeof (fileRaw as any).name !== "string" || + !(fileRaw as any).name + ) { + throw ctx.error(400, { + message: "File 'name' is missing or invalid", + }); + } + if (typeof (fileRaw as any).type !== "string") { + throw ctx.error(400, { + message: "File 'type' is missing or invalid", + }); + } + + // Safe to treat as a File-like object after the duck-type check above. + const file = fileRaw as Pick< + File, + "name" | "type" | "size" | "arrayBuffer" + >; + const context: MediaApiContext = { headers: ctx.headers }; if (hooks?.onBeforeUpload) { @@ -607,7 +651,9 @@ export const mediaBackendPlugin = (config: MediaBackendConfig) => const buffer = Buffer.from(await file.arrayBuffer()); const folderId = - (formData.get("folderId") as string | undefined) ?? undefined; + typeof body.folderId === "string" && body.folderId + ? body.folderId + : undefined; if (folderId) { const folder = await getFolderById(adapter, folderId); diff --git a/packages/stack/src/plugins/media/client.css b/packages/stack/src/plugins/media/client.css new file mode 100644 index 00000000..b43b676b --- /dev/null +++ b/packages/stack/src/plugins/media/client.css @@ -0,0 +1 @@ +/* Media plugin client styles — included in consumer CSS via @import "@btst/stack/plugins/media/css" */ diff --git a/packages/stack/src/plugins/media/client/components/index.tsx b/packages/stack/src/plugins/media/client/components/index.tsx new file mode 100644 index 00000000..75f9e0cc --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/index.tsx @@ -0,0 +1,6 @@ +export { + MediaPicker, + ImageInputField, + type MediaPickerProps, +} from "./media-picker"; +export { LibraryPageComponent } from "./pages/library-page"; diff --git a/packages/stack/src/plugins/media/client/components/media-picker/asset-card.tsx b/packages/stack/src/plugins/media/client/components/media-picker/asset-card.tsx new file mode 100644 index 00000000..957a88ae --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/asset-card.tsx @@ -0,0 +1,150 @@ +import { useDeleteAsset } from "../../hooks/use-media"; +import type { SerializedAsset } from "../../../types"; +import { cn } from "@workspace/ui/lib/utils"; +import { File, Check, Copy, Trash2 } from "lucide-react"; +import { isImage, formatBytes } from "./utils"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { MediaPluginOverrides } from "../../overrides"; +import { AssetPreviewButton } from "./asset-preview-button"; +import { toast } from "sonner"; + +export function AssetCard({ + asset, + onToggle, + selected = false, + onDelete, + apiBaseURL, +}: { + asset: SerializedAsset; + selected?: boolean; + onToggle?: () => void; + onDelete?: (id: string) => void | Promise; + apiBaseURL?: string; +}) { + const { mutateAsync: deleteAsset } = useDeleteAsset(); + const { Image: ImageComponent } = usePluginOverrides< + MediaPluginOverrides, + Partial + >("media", {}); + const imageAsset = isImage(asset.mimeType); + const selectable = typeof onToggle === "function"; + + const copyUrl = () => { + let fullUrl: string; + try { + fullUrl = new URL(asset.url, apiBaseURL).href; + } catch { + fullUrl = asset.url; + } + navigator.clipboard + .writeText(fullUrl) + .then(() => toast.success("URL copied")); + }; + + const handleDelete = () => { + if (onDelete) { + return onDelete(asset.id); + } + + if (confirm(`Delete "${asset.originalName}"?`)) { + return deleteAsset(asset.id).catch(console.error); + } + }; + + return ( +
{ + if (selectable && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onToggle(); + } + }} + className={cn( + "group relative cursor-pointer rounded-md border bg-muted/30 p-1 transition-all hover:border-ring hover:shadow-sm", + !selectable && "cursor-default", + selected && "border-ring ring-1 ring-ring", + )} + > + {/* Thumbnail */} +
+ {imageAsset ? ( + ImageComponent ? ( + + ) : ( + {asset.alt + ) + ) : ( + + )} +
+ + {/* Name + size */} +
+

+ {asset.originalName} +

+

+ {formatBytes(asset.size)} +

+
+ + {/* Selection indicator */} + {selected && ( +
+ +
+ )} + +
+ {apiBaseURL ? ( + + ) : null} + {imageAsset ? ( + + ) : null} + +
+
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.tsx b/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.tsx new file mode 100644 index 00000000..7421203b --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog"; +import { Eye, X } from "lucide-react"; +import type { SerializedAsset } from "../../../types"; + +export function AssetPreviewButton({ + asset, + className, +}: { + asset: SerializedAsset; + className: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + + {asset.alt || asset.originalName} + + + + + + +
+
+ {asset.alt +
+
+
+
+ + ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.tsx b/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.tsx new file mode 100644 index 00000000..f3812d18 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.tsx @@ -0,0 +1,116 @@ +import { useState, useRef } from "react"; +import { useAssets } from "../../hooks/use-media"; +import type { SerializedAsset } from "../../../types"; +import { Input } from "@workspace/ui/components/input"; +import { Button } from "@workspace/ui/components/button"; +import { Loader2, Search, X, Image } from "lucide-react"; +import { AssetCard } from "./asset-card"; +import { matchesAccept } from "./utils"; + +export function BrowseTab({ + folderId, + selected = [], + accept, + onToggle, + onDelete, + apiBaseURL, + emptyMessage = "No files found", +}: { + folderId: string | null; + selected?: SerializedAsset[]; + accept?: string[]; + onToggle?: (asset: SerializedAsset) => void; + onDelete?: (id: string) => void | Promise; + apiBaseURL?: string; + emptyMessage?: string; +}) { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const debounceRef = useRef | null>(null); + const selectable = typeof onToggle === "function"; + + const handleSearch = (v: string) => { + setSearch(v); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setDebouncedSearch(v), 300); + }; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useAssets({ + folderId: folderId ?? undefined, + query: debouncedSearch || undefined, + limit: 40, + }); + + const allAssets = data?.pages.flatMap((p) => p.items) ?? []; + const filtered = accept + ? allAssets.filter((a) => matchesAccept(a.mimeType, accept)) + : allAssets; + + return ( +
+
+ + handleSearch(e.target.value)} + placeholder="Search files…" + className="h-8 pl-7 text-sm" + /> + {search && ( + + )} +
+ + {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ +

{emptyMessage}

+
+ ) : ( +
+
+ {filtered.map((asset) => ( + s.id === asset.id)} + onToggle={selectable ? () => onToggle(asset) : undefined} + onDelete={onDelete} + apiBaseURL={apiBaseURL} + /> + ))} +
+ {hasNextPage && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.tsx b/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.tsx new file mode 100644 index 00000000..bbeb1ff8 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { + useFolders, + useCreateFolder, + useDeleteFolder, +} from "../../hooks/use-media"; +import type { SerializedFolder } from "../../../types"; +import { FolderPlus } from "lucide-react"; +import { Input } from "@workspace/ui/components/input"; +import { Check, Folder, Trash2, ChevronRight, FolderOpen } from "lucide-react"; +import { cn } from "@workspace/ui/lib/utils"; + +export function FolderTree({ + selectedId, + onSelect, +}: { + selectedId: string | null; + onSelect: (id: string | null) => void; +}) { + const { data: rootFoldersRaw = [] } = useFolders(null); + const rootFolders = + rootFoldersRaw as import("../../../types").SerializedFolder[]; + const [newFolderName, setNewFolderName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + + const handleCreateFolder = async () => { + const name = newFolderName.trim(); + if (!name) return; + try { + await createFolder({ name, parentId: selectedId ?? undefined }); + setNewFolderName(""); + setIsCreating(false); + } catch (err) { + console.error("[btst/media] Failed to create folder", err); + } + }; + + return ( +
+
+ + Folders + + +
+ + {isCreating && ( +
+ setNewFolderName(e.target.value)} + placeholder="Folder name" + className="h-6 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateFolder(); + if (e.key === "Escape") setIsCreating(false); + }} + /> + +
+ )} + +
+ {/* All assets (root) */} + + + {rootFolders.map((folder) => ( + + ))} +
+ + {selectedId && ( +
+ +
+ )} +
+ ); +} + +export function FolderTreeItem({ + folder, + selectedId, + onSelect, + depth = 0, +}: { + folder: SerializedFolder; + selectedId: string | null; + onSelect: (id: string | null) => void; + depth?: number; +}) { + const [expanded, setExpanded] = useState(false); + const { data: children = [] } = useFolders(folder.id); + + return ( +
+ + {expanded && + children.map((child) => ( + + ))} +
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/index.tsx b/packages/stack/src/plugins/media/client/components/media-picker/index.tsx new file mode 100644 index 00000000..55ec9b11 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/index.tsx @@ -0,0 +1,347 @@ +"use client"; +import { useState, type ReactNode } from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@workspace/ui/components/popover"; +import { Button } from "@workspace/ui/components/button"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@workspace/ui/components/tabs"; +import { Image, Upload, Link, X } from "lucide-react"; +import type { SerializedAsset } from "../../../types"; +import { FolderTree } from "./folder-tree"; +import { BrowseTab } from "./browse-tab"; +import { UploadTab } from "./upload-tab"; +import { UrlTab } from "./url-tab"; +import type { MediaPluginOverrides } from "../../overrides"; +import { usePluginOverrides } from "@btst/stack/context"; + +export interface MediaPickerProps { + /** + * Element that triggers opening the picker. Required. + */ + trigger: ReactNode; + /** + * Called when the user confirms their selection. + */ + onSelect: (assets: SerializedAsset[]) => void; + /** + * Allow multiple selection. + * @default false + */ + multiple?: boolean; + /** + * Filter displayed assets by MIME type prefix (e.g. "image/"). + */ + accept?: string[]; +} + +/** + * MediaPicker — a Popover-based media browser. + * + * Reads API config from the `media` plugin overrides context (set up in StackProvider). + * Must be rendered inside a `StackProvider` that includes media overrides. + * + * @example + * ```tsx + * Browse media} + * accept={["image/*"]} + * onSelect={(assets) => form.setValue("image", assets[0].url)} + * /> + * ``` + */ +export function MediaPicker({ + trigger, + onSelect, + multiple = false, + accept, +}: MediaPickerProps) { + const [open, setOpen] = useState(false); + const [selectedFolder, setSelectedFolder] = useState(null); + const [selectedAssets, setSelectedAssets] = useState([]); + const [activeTab, setActiveTab] = useState<"browse" | "upload" | "url">( + "browse", + ); + + const handleClose = () => { + setOpen(false); + setSelectedAssets([]); + }; + + const handleConfirm = () => { + if (selectedAssets.length === 0) return; + // Copy selection before clearing; defer onSelect so the popover has time + // to start its close animation before any parent state updates that might + // unmount this component (e.g. CMSFileUpload hiding when previewUrl is set). + const toSelect = [...selectedAssets]; + handleClose(); + setTimeout(() => onSelect(toSelect), 0); + }; + + const handleToggleAsset = (asset: SerializedAsset) => { + if (multiple) { + setSelectedAssets((prev) => + prev.some((a) => a.id === asset.id) + ? prev.filter((a) => a.id !== asset.id) + : [...prev, asset], + ); + } else { + setSelectedAssets([asset]); + } + }; + + const handleUploaded = (asset: SerializedAsset) => { + if (multiple) { + setSelectedAssets((prev) => [...prev, asset]); + } else { + setSelectedAssets([asset]); + setActiveTab("browse"); + } + }; + + const handleUrlRegistered = (asset: SerializedAsset) => { + // Close the popover first, then notify parent — same deferral as handleConfirm. + const toSelect = asset; + handleClose(); + setTimeout(() => onSelect([toSelect]), 0); + }; + + return ( + { + if (!v) handleClose(); + else setOpen(true); + }} + > + {trigger} + +
+ {/* Header */} +
+ Media Library + +
+ + {/* Body */} +
+ {/* Folder sidebar */} +
+ +
+ + {/* Main panel */} +
+ setActiveTab(v as any)} + className="flex flex-1 flex-col min-h-0" + > + + + + Browse + + + + Upload + + + + URL + + + +
+ + + + + + + + + +
+
+
+
+ + {/* Footer */} +
+ + {selectedAssets.length > 0 + ? `${selectedAssets.length} selected` + : "Click a file to select it"} + +
+ + +
+
+
+
+
+ ); +} + +/** + * ImageInputField — displays an image preview with change/remove actions, or a + * "Browse Media" button that opens the full MediaPicker popover (Browse / Upload / URL tabs). + * + * Upload mode, folder selection, and multi-mode cloud support are all handled inside + * the MediaPicker's UploadTab — this component is purely a thin wrapper. + */ +export function ImageInputField({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + const { Image: ImageComponent } = usePluginOverrides< + MediaPluginOverrides, + Partial + >("media", {}); + + if (value) { + return ( +
+ {ImageComponent ? ( + + ) : ( + Featured image preview + )} +
+ + Change Image + + } + accept={["image/*"]} + onSelect={(assets) => onChange(assets[0]?.url ?? "")} + /> + +
+
+ ); + } + + return ( +
+ + Browse Media + + } + accept={["image/*"]} + onSelect={(assets) => onChange(assets[0]?.url ?? "")} + /> +
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.tsx b/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.tsx new file mode 100644 index 00000000..031f3ef6 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.tsx @@ -0,0 +1,108 @@ +import { useState, useCallback, useRef } from "react"; +import { useUploadAsset } from "../../hooks/use-media"; +import type { SerializedAsset } from "../../../types"; +import { Button } from "@workspace/ui/components/button"; +import { Loader2, Upload } from "lucide-react"; +import { cn } from "@workspace/ui/lib/utils"; +import { matchesAccept } from "./utils"; + +export function UploadTab({ + folderId, + accept, + onUploaded, +}: { + folderId: string | null; + accept?: string[]; + onUploaded: (asset: SerializedAsset) => void; +}) { + const [dragging, setDragging] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const { mutateAsync: uploadAsset } = useUploadAsset(); + + const acceptAttr = accept?.join(",") ?? undefined; + + const handleFiles = useCallback( + async (files: FileList | File[]) => { + const fileArr = Array.from(files); + if (fileArr.length === 0) return; + setError(null); + setUploading(true); + try { + for (const file of fileArr) { + if (accept && !matchesAccept(file.type, accept)) { + setError(`File type ${file.type} is not accepted.`); + continue; + } + const asset = await uploadAsset({ + file, + folderId: folderId ?? undefined, + }); + onUploaded(asset); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploading(false); + } + }, + [accept, folderId, uploadAsset, onUploaded], + ); + + return ( +
+
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + void handleFiles(e.dataTransfer.files); + }} + className={cn( + "flex flex-1 flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors sm:px-6", + dragging ? "border-ring bg-ring/5" : "border-muted-foreground/30", + )} + > + {uploading ? ( + <> + +

Uploading…

+ + ) : ( + <> + +
+

Drop files here

+

+ or click to browse +

+
+ + + )} +
+ {error &&

{error}

} + e.target.files && handleFiles(e.target.files)} + /> +
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/url-tab.tsx b/packages/stack/src/plugins/media/client/components/media-picker/url-tab.tsx new file mode 100644 index 00000000..3238b518 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/url-tab.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { useRegisterAsset } from "../../hooks/use-media"; +import type { SerializedAsset } from "../../../types"; +import { Input } from "@workspace/ui/components/input"; +import { Button } from "@workspace/ui/components/button"; +import { Loader2, Check } from "lucide-react"; + +export function UrlTab({ + folderId, + onRegistered, +}: { + folderId: string | null; + onRegistered: (asset: SerializedAsset) => void; +}) { + const [url, setUrl] = useState(""); + const [error, setError] = useState(null); + const { mutateAsync: registerAsset, isPending } = useRegisterAsset(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const trimmed = url.trim(); + if (!trimmed) return; + try { + const filename = trimmed.split("/").pop() ?? "asset"; + const asset = await registerAsset({ + url: trimmed, + filename, + folderId: folderId ?? undefined, + }); + setUrl(""); + onRegistered(asset); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to register URL"); + } + }; + + return ( +
+

+ Paste a public URL to register it as an asset without uploading a file. +

+ +
+ setUrl(e.target.value)} + placeholder="https://example.com/image.png" + className="flex-1" + data-testid="media-url-input" + autoFocus + /> + +
+ {error &&

{error}

} + +
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/media-picker/utils.ts b/packages/stack/src/plugins/media/client/components/media-picker/utils.ts new file mode 100644 index 00000000..b15d3e92 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/media-picker/utils.ts @@ -0,0 +1,17 @@ +export function matchesAccept(mimeType: string, accept?: string[]) { + if (!accept || accept.length === 0) return true; + return accept.some((a) => { + if (a.endsWith("/*")) return mimeType.startsWith(a.slice(0, -1)); + return mimeType === a; + }); +} + +export function isImage(mimeType: string) { + return mimeType.startsWith("image/"); +} + +export function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/packages/stack/src/plugins/media/client/components/pages/library-page.internal.tsx b/packages/stack/src/plugins/media/client/components/pages/library-page.internal.tsx new file mode 100644 index 00000000..daeb65a1 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/pages/library-page.internal.tsx @@ -0,0 +1,134 @@ +"use client"; +import { useState, useCallback, useRef } from "react"; +import { useDeleteAsset, useUploadAsset } from "../../hooks/use-media"; +import { Button } from "@workspace/ui/components/button"; +import { Upload, Loader2 } from "lucide-react"; +import { cn } from "@workspace/ui/lib/utils"; +import { toast } from "sonner"; +import { usePluginOverrides } from "@btst/stack/context"; +import type { MediaPluginOverrides } from "../../overrides"; +import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle"; +import { BrowseTab } from "../media-picker/browse-tab"; +import { FolderTree } from "../media-picker/folder-tree"; + +export function LibraryPage() { + const overrides = usePluginOverrides< + MediaPluginOverrides, + Partial + >("media", {}); + + useRouteLifecycle({ + routeName: "library", + context: { + path: "/media", + isSSR: typeof window === "undefined", + }, + overrides, + beforeRenderHook: (overrides, context) => { + if (overrides.onBeforeLibraryPageRendered) { + return overrides.onBeforeLibraryPageRendered(context); + } + return true; + }, + }); + + const [selectedFolder, setSelectedFolder] = useState(null); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + const { mutateAsync: uploadAsset, isPending: isUploading } = useUploadAsset(); + const { mutateAsync: deleteAsset } = useDeleteAsset(); + const { apiBaseURL = "" } = overrides; + + const handleUpload = useCallback( + async (files: FileList | File[]) => { + const arr = Array.from(files); + for (const file of arr) { + try { + await uploadAsset({ file, folderId: selectedFolder ?? undefined }); + toast.success(`Uploaded ${file.name}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Upload failed"); + } + } + }, + [selectedFolder, uploadAsset], + ); + + const handleDelete = async (id: string) => { + if (!confirm("Delete this asset?")) return; + try { + await deleteAsset(id); + toast.success("Deleted"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Delete failed"); + } + }; + + return ( +
+
+ +
+ +
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + void handleUpload(e.dataTransfer.files); + }} + > + {/* Toolbar */} +
+ + e.target.files && handleUpload(e.target.files)} + /> +
+ + {/* Drop overlay */} + {dragging && ( +
+
+ +

Drop files to upload

+
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/packages/stack/src/plugins/media/client/components/pages/library-page.tsx b/packages/stack/src/plugins/media/client/components/pages/library-page.tsx new file mode 100644 index 00000000..f6c424b0 --- /dev/null +++ b/packages/stack/src/plugins/media/client/components/pages/library-page.tsx @@ -0,0 +1,42 @@ +"use client"; +import { lazy } from "react"; +import type { FallbackProps } from "react-error-boundary"; +import { usePluginOverrides } from "@btst/stack/context"; +import { ComposedRoute } from "@btst/stack/client/components"; +import type { MediaPluginOverrides } from "../../overrides"; +import { Loader2 } from "lucide-react"; + +const LibraryPage = lazy(() => + import("./library-page.internal").then((m) => ({ default: m.LibraryPage })), +); + +function LibraryLoading() { + return ( +
+ +
+ ); +} + +function LibraryError({ error }: FallbackProps) { + const message = error instanceof Error ? error.message : String(error); + return ( +
+

{message}

+
+ ); +} + +export function LibraryPageComponent() { + usePluginOverrides("media"); + return ( + null} + onError={(error) => console.error("[btst/media] Library error:", error)} + /> + ); +} diff --git a/packages/stack/src/plugins/media/client/hooks/index.tsx b/packages/stack/src/plugins/media/client/hooks/index.tsx new file mode 100644 index 00000000..8daddbc2 --- /dev/null +++ b/packages/stack/src/plugins/media/client/hooks/index.tsx @@ -0,0 +1,9 @@ +export { + useAssets, + useFolders, + useUploadAsset, + useRegisterAsset, + useDeleteAsset, + useCreateFolder, + useDeleteFolder, +} from "./use-media"; diff --git a/packages/stack/src/plugins/media/client/hooks/use-media.tsx b/packages/stack/src/plugins/media/client/hooks/use-media.tsx new file mode 100644 index 00000000..ab345cb0 --- /dev/null +++ b/packages/stack/src/plugins/media/client/hooks/use-media.tsx @@ -0,0 +1,289 @@ +"use client"; +import { + useInfiniteQuery, + useQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { usePluginOverrides } from "@btst/stack/context"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { MediaApiRouter } from "../../api/plugin"; +import type { MediaPluginOverrides } from "../overrides"; +import { createMediaQueryKeys } from "../../query-keys"; +import type { AssetListParams } from "../../api/getters"; +import type { SerializedAsset, SerializedFolder } from "../../types"; +import { uploadAsset } from "../upload"; + +function useMediaConfig() { + return usePluginOverrides("media"); +} + +function useMediaApiClient() { + const { apiBaseURL, apiBasePath, headers } = useMediaConfig(); + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }); + return { client, headers }; +} + +/** + * Infinite-scroll list of assets, optionally filtered by folder / MIME type / search. + */ +export function useAssets(params?: AssetListParams) { + const { client, headers } = useMediaApiClient(); + const queries = createMediaQueryKeys(client, headers); + const { queryClient } = useMediaConfig(); + + const limit = params?.limit ?? 20; + + return useInfiniteQuery( + { + ...queries.mediaAssets.list(params), + initialPageParam: 0, + refetchOnMount: "always", + getNextPageParam: ( + lastPage: { + items: SerializedAsset[]; + total: number; + limit?: number; + offset?: number; + }, + _allPages: any[], + lastPageParam: number, + ) => { + const offset = (lastPage.offset ?? 0) + lastPage.items.length; + return offset < lastPage.total ? offset : undefined; + }, + }, + queryClient, + ); +} + +/** + * List of folders, optionally filtered by parentId. + * Pass `null` for root-level folders, `undefined` for all folders. + */ +export function useFolders(parentId?: string | null) { + const { client, headers } = useMediaApiClient(); + const queries = createMediaQueryKeys(client, headers); + const { queryClient } = useMediaConfig(); + + return useQuery( + { + ...queries.mediaFolders.list(parentId), + }, + queryClient, + ); +} + +/** + * Upload an asset — adapter-aware. Handles direct, S3, and Vercel Blob flows. + */ +export function useUploadAsset() { + const { + apiBaseURL, + apiBasePath, + headers, + uploadMode = "direct", + imageCompression, + queryClient: qc, + } = useMediaConfig(); + const reactQueryClient = useQueryClient(qc); + + return useMutation( + { + mutationFn: async ({ + file, + folderId, + }: { + file: File; + folderId?: string; + }): Promise => + uploadAsset( + { + apiBaseURL, + apiBasePath, + headers, + uploadMode, + imageCompression, + }, + { file, folderId }, + ), + onSuccess: () => { + reactQueryClient.invalidateQueries({ queryKey: ["mediaAssets"] }); + }, + }, + qc, + ); +} + +/** + * Register an asset URL directly (for when the URL already exists). + */ +export function useRegisterAsset() { + const { + apiBaseURL, + apiBasePath, + headers, + queryClient: qc, + } = useMediaConfig(); + const reactQueryClient = useQueryClient(qc); + + return useMutation( + { + mutationFn: async (input: { + url: string; + filename: string; + mimeType?: string; + size?: number; + folderId?: string; + }): Promise => { + const base = `${apiBaseURL}${apiBasePath}`; + const headersObj = new Headers(headers as HeadersInit | undefined); + const res = await fetch(`${base}/media/assets`, { + method: "POST", + headers: { + ...Object.fromEntries(headersObj.entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filename: input.filename, + originalName: input.filename, + mimeType: input.mimeType ?? "application/octet-stream", + size: input.size ?? 0, + url: input.url, + folderId: input.folderId, + }), + }); + if (!res.ok) { + const err = await res + .json() + .catch(() => ({ message: res.statusText })); + throw new Error(err.message ?? "Failed to register asset"); + } + return res.json(); + }, + onSuccess: () => { + reactQueryClient.invalidateQueries({ queryKey: ["mediaAssets"] }); + }, + }, + qc, + ); +} + +/** + * Delete an asset by ID. + */ +export function useDeleteAsset() { + const { + apiBaseURL, + apiBasePath, + headers, + queryClient: qc, + } = useMediaConfig(); + const reactQueryClient = useQueryClient(qc); + + return useMutation( + { + mutationFn: async (id: string) => { + const base = `${apiBaseURL}${apiBasePath}`; + const headersObj = new Headers(headers as HeadersInit | undefined); + const res = await fetch(`${base}/media/assets/${id}`, { + method: "DELETE", + headers: headersObj, + }); + if (!res.ok) { + const err = await res + .json() + .catch(() => ({ message: res.statusText })); + throw new Error(err.message ?? "Delete failed"); + } + }, + onSuccess: () => { + reactQueryClient.invalidateQueries({ queryKey: ["mediaAssets"] }); + }, + }, + qc, + ); +} + +/** + * Create a new folder. + */ +export function useCreateFolder() { + const { + apiBaseURL, + apiBasePath, + headers, + queryClient: qc, + } = useMediaConfig(); + const reactQueryClient = useQueryClient(qc); + + return useMutation( + { + mutationFn: async (input: { + name: string; + parentId?: string; + }): Promise => { + const base = `${apiBaseURL}${apiBasePath}`; + const headersObj = new Headers(headers as HeadersInit | undefined); + const res = await fetch(`${base}/media/folders`, { + method: "POST", + headers: { + ...Object.fromEntries(headersObj.entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const err = await res + .json() + .catch(() => ({ message: res.statusText })); + throw new Error(err.message ?? "Failed to create folder"); + } + return res.json(); + }, + onSuccess: () => { + reactQueryClient.invalidateQueries({ queryKey: ["mediaFolders"] }); + }, + }, + qc, + ); +} + +/** + * Delete a folder by ID. + */ +export function useDeleteFolder() { + const { + apiBaseURL, + apiBasePath, + headers, + queryClient: qc, + } = useMediaConfig(); + const reactQueryClient = useQueryClient(qc); + + return useMutation( + { + mutationFn: async (id: string) => { + const base = `${apiBaseURL}${apiBasePath}`; + const headersObj = new Headers(headers as HeadersInit | undefined); + const res = await fetch(`${base}/media/folders/${id}`, { + method: "DELETE", + headers: headersObj, + }); + if (!res.ok) { + const err = await res + .json() + .catch(() => ({ message: res.statusText })); + throw new Error(err.message ?? "Failed to delete folder"); + } + }, + onSuccess: () => { + reactQueryClient.invalidateQueries({ queryKey: ["mediaFolders"] }); + }, + }, + qc, + ); +} diff --git a/packages/stack/src/plugins/media/client/index.ts b/packages/stack/src/plugins/media/client/index.ts new file mode 100644 index 00000000..438cabcc --- /dev/null +++ b/packages/stack/src/plugins/media/client/index.ts @@ -0,0 +1,4 @@ +export { mediaClientPlugin } from "./plugin"; +export type { MediaPluginOverrides, MediaUploadMode } from "./overrides"; +export { uploadAsset } from "./upload"; +export type { MediaUploadClientConfig, UploadAssetInput } from "./upload"; diff --git a/packages/stack/src/plugins/media/client/overrides.ts b/packages/stack/src/plugins/media/client/overrides.ts new file mode 100644 index 00000000..a62ca9f6 --- /dev/null +++ b/packages/stack/src/plugins/media/client/overrides.ts @@ -0,0 +1,127 @@ +import type { ComponentType } from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import type { ImageCompressionOptions } from "./utils/image-compression"; + +/** + * Upload mode — must match the storage adapter configured in mediaBackendPlugin. + * - `"direct"` — local filesystem adapter, files are uploaded via `POST /media/upload` + * - `"s3"` — AWS S3 / R2 / MinIO, the client fetches a presigned token then PUTs directly to S3 + * - `"vercel-blob"` — Vercel Blob, uses the `@vercel/blob/client` SDK for direct upload + */ +export type MediaUploadMode = "direct" | "s3" | "vercel-blob"; + +/** + * Overridable components and functions for the Media plugin. + * + * External consumers provide these when registering the media client plugin + * via the StackProvider overrides. + */ +export interface MediaPluginOverrides { + /** + * Base URL for API calls (e.g., "http://localhost:3000"). + */ + apiBaseURL: string; + + /** + * Path where the API is mounted (e.g., "/api/data"). + */ + apiBasePath: string; + + /** + * React Query client — used by the MediaPicker to cache and fetch assets. + */ + queryClient: QueryClient; + + /** + * Upload mode — must match the storageAdapter configured in mediaBackendPlugin. + * @default "direct" + */ + uploadMode?: MediaUploadMode; + + /** + * Optional headers to pass with API requests (e.g., for SSR auth). + */ + headers?: HeadersInit; + + /** + * Navigation function for programmatic navigation. + */ + navigate: (path: string) => void | Promise; + + /** + * Link component for navigation within the media library page. + */ + Link?: ComponentType & Record>; + + /** + * Image component for rendering asset thumbnails and previews. + * + * When provided, replaces the default `` element in asset cards, + * the media library grid, and the ImageInputField preview. Use this + * to plug in Next.js `` for automatic optimisation. + * + * @example + * ```tsx + * Image: (props) => + * ``` + */ + Image?: ComponentType< + React.ImgHTMLAttributes & Record + >; + + /** + * Client-side image compression applied before upload via the Canvas API. + * + * Images are scaled down to fit within `maxWidth` × `maxHeight` (preserving + * aspect ratio) and re-encoded at `quality`. SVG and GIF files are always + * passed through unchanged. + * + * Set to `false` to disable compression entirely. + * + * @default { maxWidth: 2048, maxHeight: 2048, quality: 0.85 } + */ + imageCompression?: ImageCompressionOptions | false; + + // ============ Lifecycle Hooks ============ + + /** + * Called when a media route is rendered. + */ + onRouteRender?: ( + routeName: string, + context: MediaRouteContext, + ) => void | Promise; + + /** + * Called when a media route encounters an error. + */ + onRouteError?: ( + routeName: string, + error: Error, + context: MediaRouteContext, + ) => void | Promise; + + /** + * Called before the media library page is rendered. + * Return `false` to prevent rendering (e.g., redirect unauthenticated users). + * + * @example + * ```ts + * media: { + * onBeforeLibraryPageRendered: (context) => !!currentUser?.isAdmin, + * onRouteError: (routeName, error, context) => navigate("/login"), + * } + * ``` + */ + onBeforeLibraryPageRendered?: (context: MediaRouteContext) => boolean; +} + +export interface MediaRouteContext { + /** Current route path */ + path: string; + /** Route parameters */ + params?: Record; + /** Whether rendering on server (true) or client (false) */ + isSSR: boolean; + [key: string]: unknown; +} diff --git a/packages/stack/src/plugins/media/client/plugin.tsx b/packages/stack/src/plugins/media/client/plugin.tsx new file mode 100644 index 00000000..df1fb7aa --- /dev/null +++ b/packages/stack/src/plugins/media/client/plugin.tsx @@ -0,0 +1,184 @@ +import { + defineClientPlugin, + createApiClient, + isConnectionError, +} from "@btst/stack/plugins/client"; +import { createRoute } from "@btst/yar"; +import type { ComponentType } from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import { LibraryPageComponent } from "./components/pages/library-page"; +import { createMediaQueryKeys } from "../query-keys"; +import type { MediaApiRouter } from "../api/plugin"; + +export interface MediaLoaderContext { + path: string; + isSSR: boolean; + apiBaseURL: string; + apiBasePath: string; + headers?: HeadersInit; +} + +export interface MediaClientHooks { + /** Called before the media library data is fetched during SSR. Throw to cancel. */ + beforeLoadLibrary?: (context: MediaLoaderContext) => Promise | void; + + /** Called after the media library data is fetched during SSR. */ + afterLoadLibrary?: (context: MediaLoaderContext) => Promise | void; + + /** Called when an error occurs during the SSR loader. */ + onLoadError?: ( + error: Error, + context: MediaLoaderContext, + ) => Promise | void; +} + +export interface MediaClientConfig { + /** Base URL for API calls (e.g., "http://localhost:3000") */ + apiBaseURL: string; + /** Path where the API is mounted (e.g., "/api/data") */ + apiBasePath: string; + /** Base URL of your site for SEO meta tags */ + siteBaseURL: string; + /** Path where pages are mounted (e.g., "/pages") */ + siteBasePath: string; + /** React Query client — used by the SSR loader to prefetch data */ + queryClient: QueryClient; + /** Optional headers forwarded with SSR API requests (e.g. auth cookies) */ + headers?: HeadersInit; + /** Optional lifecycle hooks for the media client plugin */ + hooks?: MediaClientHooks; + /** + * Optional page component overrides. + * Replace any plugin page with a custom React component. + * The built-in component is used as the fallback when not provided. + */ + pageComponents?: { + /** Replaces the media library page */ + library?: ComponentType; + }; +} + +/** + * Media client plugin. + * Registers the /media library route. + * + * Configure overrides in StackProvider: + * ```tsx + * + * ``` + * + * @example + * ```ts + * import { mediaClientPlugin } from "@btst/stack/plugins/media/client" + * + * const clientPlugins = [ + * mediaClientPlugin({ apiBaseURL, apiBasePath, siteBaseURL, siteBasePath, queryClient }), + * // ...other plugins + * ] + * ``` + */ +export const mediaClientPlugin = (config: MediaClientConfig) => + defineClientPlugin({ + name: "media", + + routes: () => ({ + library: createRoute("/media", () => { + const CustomLibrary = config.pageComponents?.library; + return { + PageComponent: CustomLibrary ?? LibraryPageComponent, + loader: createMediaLibraryLoader(config), + meta: createMediaLibraryMeta(config), + }; + }), + }), + }); + +function createMediaLibraryLoader(config: MediaClientConfig) { + return async () => { + if (typeof window === "undefined") { + const { queryClient, apiBasePath, apiBaseURL, hooks, headers } = config; + + const context: MediaLoaderContext = { + path: "/media", + isSSR: true, + apiBaseURL, + apiBasePath, + headers, + }; + + try { + if (hooks?.beforeLoadLibrary) { + await hooks.beforeLoadLibrary(context); + } + + const client = createApiClient({ + baseURL: apiBaseURL, + basePath: apiBasePath, + }); + const queries = createMediaQueryKeys(client, headers); + + // Prefetch initial asset grid (infinite query — root folder, default limit) + await queryClient.prefetchInfiniteQuery({ + ...queries.mediaAssets.list({ limit: 40 }), + initialPageParam: 0, + }); + + // Prefetch root-level folders for the sidebar tree + await queryClient.prefetchQuery(queries.mediaFolders.list(null)); + + if (hooks?.afterLoadLibrary) { + await hooks.afterLoadLibrary(context); + } + + const queryState = queryClient.getQueryState( + queries.mediaAssets.list({ limit: 40 }).queryKey, + ); + if (queryState?.error && hooks?.onLoadError) { + const error = + queryState.error instanceof Error + ? queryState.error + : new Error(String(queryState.error)); + await hooks.onLoadError(error, context); + } + } catch (error) { + if (isConnectionError(error)) { + console.warn( + "[btst/media] route.loader() failed — no server running at build time. " + + "The media library does not support SSG.", + ); + } + if (hooks?.onLoadError) { + await hooks.onLoadError(error as Error, context); + } + } + } + }; +} + +function createMediaLibraryMeta(config: MediaClientConfig) { + return () => { + const { siteBaseURL, siteBasePath } = config; + const fullUrl = `${siteBaseURL}${siteBasePath}/media`; + const title = "Media Library"; + + return [ + { title }, + { name: "title", content: title }, + { name: "description", content: "Manage your media assets" }, + { name: "robots", content: "noindex, nofollow" }, + + // Open Graph + { property: "og:type", content: "website" }, + { property: "og:title", content: title }, + { + property: "og:description", + content: "Manage your media assets", + }, + { property: "og:url", content: fullUrl }, + + // Twitter + { name: "twitter:card", content: "summary" }, + { name: "twitter:title", content: title }, + ]; + }; +} diff --git a/packages/stack/src/plugins/media/client/upload.ts b/packages/stack/src/plugins/media/client/upload.ts new file mode 100644 index 00000000..89d70ec0 --- /dev/null +++ b/packages/stack/src/plugins/media/client/upload.ts @@ -0,0 +1,171 @@ +"use client"; + +import type { SerializedAsset } from "../types"; +import type { MediaPluginOverrides } from "./overrides"; +import { compressImage } from "./utils/image-compression"; + +export type MediaUploadClientConfig = Pick< + MediaPluginOverrides, + "apiBaseURL" | "apiBasePath" | "headers" | "uploadMode" | "imageCompression" +>; + +export interface UploadAssetInput { + file: File; + folderId?: string; +} + +const DEFAULT_IMAGE_COMPRESSION = { + maxWidth: 2048, + maxHeight: 2048, + quality: 0.85, +} as const; + +/** + * Upload an asset using the media plugin's configured storage mode. + * + * Use this in non-React contexts like editor `uploadImage` callbacks. React + * components should usually prefer `useUploadAsset()`, which wraps this helper + * and handles cache invalidation. + */ +export async function uploadAsset( + config: MediaUploadClientConfig, + input: UploadAssetInput, +): Promise { + const { + apiBaseURL, + apiBasePath, + headers, + uploadMode = "direct", + imageCompression, + } = config; + const { file, folderId } = input; + + const processedFile = + imageCompression === false + ? file + : await compressImage( + file, + imageCompression ?? DEFAULT_IMAGE_COMPRESSION, + ); + + const base = `${apiBaseURL}${apiBasePath}`; + const headersObj = new Headers(headers as HeadersInit | undefined); + + if (uploadMode === "direct") { + const formData = new FormData(); + formData.append("file", processedFile); + if (folderId) formData.append("folderId", folderId); + + const res = await fetch(`${base}/media/upload`, { + method: "POST", + headers: headersObj, + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(err.message ?? "Upload failed"); + } + return res.json(); + } + + if (uploadMode === "s3") { + const tokenRes = await fetch(`${base}/media/upload/token`, { + method: "POST", + headers: { + ...Object.fromEntries(headersObj.entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filename: processedFile.name, + mimeType: processedFile.type, + size: processedFile.size, + folderId, + }), + }); + if (!tokenRes.ok) { + const err = await tokenRes + .json() + .catch(() => ({ message: tokenRes.statusText })); + throw new Error(err.message ?? "Failed to get upload token"); + } + + const token = (await tokenRes.json()) as { + type: "presigned-url"; + payload: { + uploadUrl: string; + publicUrl: string; + key: string; + method: "PUT"; + headers: Record; + }; + }; + + const putRes = await fetch(token.payload.uploadUrl, { + method: "PUT", + headers: token.payload.headers, + body: processedFile, + }); + if (!putRes.ok) throw new Error("Failed to upload to S3"); + + const assetRes = await fetch(`${base}/media/assets`, { + method: "POST", + headers: { + ...Object.fromEntries(headersObj.entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filename: processedFile.name, + originalName: file.name, + mimeType: processedFile.type, + size: processedFile.size, + url: token.payload.publicUrl, + folderId, + }), + }); + if (!assetRes.ok) { + const err = await assetRes + .json() + .catch(() => ({ message: assetRes.statusText })); + throw new Error(err.message ?? "Failed to register asset"); + } + return assetRes.json(); + } + + if (uploadMode === "vercel-blob") { + // Dynamic import keeps @vercel/blob/client optional. + const { upload } = await import("@vercel/blob/client"); + const blob = await upload(processedFile.name, processedFile, { + access: "public", + handleUploadUrl: `${base}/media/upload/vercel-blob`, + clientPayload: JSON.stringify({ + mimeType: processedFile.type, + size: processedFile.size, + }), + }); + + const assetRes = await fetch(`${base}/media/assets`, { + method: "POST", + headers: { + ...Object.fromEntries(headersObj.entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filename: processedFile.name, + originalName: file.name, + mimeType: processedFile.type, + size: processedFile.size, + url: blob.url, + folderId, + }), + }); + if (!assetRes.ok) { + const err = await assetRes + .json() + .catch(() => ({ message: assetRes.statusText })); + throw new Error(err.message ?? "Failed to register asset"); + } + return assetRes.json(); + } + + throw new Error(`Unknown uploadMode: ${uploadMode}`); +} diff --git a/packages/stack/src/plugins/media/client/utils/image-compression.ts b/packages/stack/src/plugins/media/client/utils/image-compression.ts new file mode 100644 index 00000000..b7111d8d --- /dev/null +++ b/packages/stack/src/plugins/media/client/utils/image-compression.ts @@ -0,0 +1,131 @@ +/** + * Canvas-based client-side image compression. + * + * Skips SVG and GIF (vector data / animation would be lost on a canvas round-trip). + * All other image/* types are scaled down to fit within maxWidth × maxHeight + * (preserving aspect ratio) and re-encoded at the configured quality. + */ + +export interface ImageCompressionOptions { + /** + * Maximum width in pixels. Images wider than this are scaled down. + * @default 2048 + */ + maxWidth?: number; + + /** + * Maximum height in pixels. Images taller than this are scaled down. + * @default 2048 + */ + maxHeight?: number; + + /** + * Encoding quality (0–1). Applies to JPEG and WebP. + * @default 0.85 + */ + quality?: number; + + /** + * Output MIME type. Defaults to the source image's MIME type. + * Set to `"image/webp"` for better compression at the cost of format change. + */ + outputFormat?: string; +} + +function loadImage(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error(`Failed to load image: ${file.name}`)); + }; + img.src = url; + }); +} + +const SKIP_TYPES = new Set(["image/svg+xml", "image/gif"]); + +/** + * Compresses an image file client-side using the Canvas API. + * + * Returns the original file unchanged if: + * - The file is not an image + * - The MIME type is SVG or GIF (would lose vector data / animation) + * - The browser does not support canvas (SSR guard) + */ +export async function compressImage( + file: File, + options: ImageCompressionOptions = {}, +): Promise { + if (!file.type.startsWith("image/") || SKIP_TYPES.has(file.type)) { + return file; + } + + // SSR guard — canvas is only available in the browser + if (typeof document === "undefined") return file; + + const { + maxWidth = 2048, + maxHeight = 2048, + quality = 0.85, + outputFormat, + } = options; + + const img = await loadImage(file); + + let { width, height } = img; + + const needsResize = width > maxWidth || height > maxHeight; + const needsFormatChange = + outputFormat !== undefined && outputFormat !== file.type; + + // Skip canvas entirely if the image is already within the limits and no + // format conversion is needed — re-encoding a small image can make it larger. + if (!needsResize && !needsFormatChange) return file; + + // Scale down proportionally if either dimension exceeds the max + if (needsResize) { + const ratio = Math.min(maxWidth / width, maxHeight / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) return file; + + ctx.drawImage(img, 0, 0, width, height); + + const mimeType = outputFormat ?? file.type; + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error("canvas.toBlob returned null")); + return; + } + + // Preserve the original filename, updating extension only if + // the output format changed from the source. + let name = file.name; + if (outputFormat && outputFormat !== file.type) { + const ext = outputFormat.split("/")[1] ?? "jpg"; + name = name.replace(/\.[^.]+$/, `.${ext}`); + } + + resolve(new File([blob], name, { type: mimeType })); + }, + mimeType, + quality, + ); + }); +} diff --git a/packages/stack/src/plugins/media/query-keys.ts b/packages/stack/src/plugins/media/query-keys.ts new file mode 100644 index 00000000..d892e33d --- /dev/null +++ b/packages/stack/src/plugins/media/query-keys.ts @@ -0,0 +1,96 @@ +import { + mergeQueryKeys, + createQueryKeys, +} from "@lukemorales/query-key-factory"; +import { createApiClient } from "@btst/stack/plugins/client"; +import type { MediaApiRouter } from "./api/plugin"; +import type { SerializedAsset, SerializedFolder } from "./types"; +import { assetListDiscriminator } from "./api/query-key-defs"; +import type { AssetListParams } from "./api/getters"; + +function isErrorResponse(response: unknown): response is { error: unknown } { + return ( + typeof response === "object" && + response !== null && + "error" in response && + (response as Record).error !== null && + (response as Record).error !== undefined + ); +} + +function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "object" && error !== null) { + const errorObj = error as Record; + const message = + (typeof errorObj.message === "string" ? errorObj.message : null) || + JSON.stringify(error); + const err = new Error(message); + Object.assign(err, error); + return err; + } + return new Error(String(error)); +} + +export function createMediaQueryKeys( + client: ReturnType>, + headers?: HeadersInit, +) { + return mergeQueryKeys( + createQueryKeys("mediaAssets", { + list: (params?: AssetListParams) => ({ + queryKey: [assetListDiscriminator(params)], + queryFn: async ({ pageParam }: { pageParam?: number }) => { + const response = await (client as any)("/media/assets", { + method: "GET", + query: { + folderId: params?.folderId, + mimeType: params?.mimeType, + query: params?.query, + offset: pageParam ?? params?.offset ?? 0, + limit: params?.limit ?? 20, + }, + headers, + }); + if (isErrorResponse(response)) throw toError(response.error); + const data = (response as any).data as { + items: SerializedAsset[]; + total: number; + limit?: number; + offset?: number; + }; + return data; + }, + }), + detail: (id: string) => ({ + queryKey: [id], + queryFn: async () => { + const response = await (client as any)("/media/assets", { + method: "GET", + query: { id }, + headers, + }); + if (isErrorResponse(response)) throw toError(response.error); + return (response as any).data as SerializedAsset | null; + }, + }), + }), + createQueryKeys("mediaFolders", { + list: (parentId?: string | null) => ({ + queryKey: [parentId ?? "root"], + queryFn: async () => { + const response = await (client as any)("/media/folders", { + method: "GET", + query: + parentId !== undefined ? { parentId: parentId ?? undefined } : {}, + headers, + }); + if (isErrorResponse(response)) throw toError(response.error); + return (response as any).data as SerializedFolder[]; + }, + }), + }), + ); +} + +export type MediaQueryKeys = ReturnType; diff --git a/packages/stack/src/plugins/media/schemas.ts b/packages/stack/src/plugins/media/schemas.ts index 052b9b17..ee1147a1 100644 --- a/packages/stack/src/plugins/media/schemas.ts +++ b/packages/stack/src/plugins/media/schemas.ts @@ -12,8 +12,9 @@ export const createAssetSchema = z.object({ filename: z.string().min(1), originalName: z.string().min(1), mimeType: z.string().min(1), - size: z.number().int().positive(), - url: z.string().url(), + // Allow 0 for URL-registered assets where size is unknown at registration time. + size: z.number().int().min(0), + url: z.httpUrl(), folderId: z.string().optional(), alt: z.string().optional(), }); diff --git a/packages/stack/src/plugins/media/style.css b/packages/stack/src/plugins/media/style.css new file mode 100644 index 00000000..7513c307 --- /dev/null +++ b/packages/stack/src/plugins/media/style.css @@ -0,0 +1 @@ +@source "./client/**/*.{ts,tsx}"; diff --git a/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.tsx b/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.tsx index 8a13b4e0..77976c10 100644 --- a/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.tsx +++ b/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.tsx @@ -1,17 +1,22 @@ import * as React from "react" import type { Editor } from "@tiptap/react" +import type { ComponentType } from "react" import { Button } from "@workspace/ui/components/button" import { Label } from "@workspace/ui/components/label" import { Input } from "@workspace/ui/components/input" +import { Separator } from "@workspace/ui/components/separator" interface ImageEditBlockProps { editor: Editor close: () => void + /** Optional trigger for a media library picker. When provided, rendered as a "Browse media" section. */ + imagePickerTrigger?: ComponentType<{ onSelect: (url: string) => void }> } export const ImageEditBlock: React.FC = ({ editor, close, + imagePickerTrigger: ImagePickerTrigger, }) => { const fileInputRef = React.useRef(null) const [link, setLink] = React.useState("") @@ -79,6 +84,22 @@ export const ImageEditBlock: React.FC = ({ + {ImagePickerTrigger && ( + <> + +
+ + { + editor.commands.setImages([{ src: url }]) + close() + }} + /> +
+ + )} { editor: Editor + imagePickerTrigger?: ComponentType<{ onSelect: (url: string) => void }> } -const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => { +const ImageEditDialog = ({ editor, size, variant, imagePickerTrigger }: ImageEditDialogProps) => { const [open, setOpen] = useState(false) return ( @@ -41,7 +43,7 @@ const ImageEditDialog = ({ editor, size, variant }: ImageEditDialogProps) => { Upload an image from your computer - setOpen(false)} /> + setOpen(false)} imagePickerTrigger={imagePickerTrigger} /> ) diff --git a/packages/ui/src/components/minimal-tiptap/components/section/five.tsx b/packages/ui/src/components/minimal-tiptap/components/section/five.tsx index c325e909..0623b7a2 100644 --- a/packages/ui/src/components/minimal-tiptap/components/section/five.tsx +++ b/packages/ui/src/components/minimal-tiptap/components/section/five.tsx @@ -1,5 +1,6 @@ import * as React from "react" import type { Editor } from "@tiptap/react" +import type { ComponentType } from "react" import type { FormatAction } from "../../types" import type { toggleVariants } from "@workspace/ui/components/toggle" import type { VariantProps } from "class-variance-authority" @@ -56,6 +57,7 @@ interface SectionFiveProps extends VariantProps { editor: Editor activeActions?: InsertElementAction[] mainActionCount?: number + imagePickerTrigger?: ComponentType<{ onSelect: (url: string) => void }> } export const SectionFive: React.FC = ({ @@ -64,11 +66,12 @@ export const SectionFive: React.FC = ({ mainActionCount = 0, size, variant, + imagePickerTrigger, }) => { return ( <> - + void className?: string editorContentClassName?: string + /** + * Optional trigger component for a media picker. + * When provided, it appears inside the image insertion dialog as a "Browse media" section. + * Receives `onSelect(url)` — the URL is inserted as an image node. + */ + imagePickerTrigger?: ComponentType<{ onSelect: (url: string) => void }> } -const Toolbar = ({ editor }: { editor: Editor }) => ( +const Toolbar = ({ + editor, + imagePickerTrigger, +}: { + editor: Editor + imagePickerTrigger?: ComponentType<{ onSelect: (url: string) => void }> +}) => (
@@ -63,6 +76,7 @@ const Toolbar = ({ editor }: { editor: Editor }) => ( editor={editor} activeActions={["codeBlock", "blockquote", "horizontalRule"]} mainActionCount={0} + imagePickerTrigger={imagePickerTrigger} />
@@ -73,6 +87,7 @@ export const MinimalTiptapEditor = ({ onChange, className, editorContentClassName, + imagePickerTrigger, ...props }: MinimalTiptapProps) => { const editor = useMinimalTiptapEditor({ @@ -91,6 +106,7 @@ export const MinimalTiptapEditor = ({ editor={editor} className={className} editorContentClassName={editorContentClassName} + imagePickerTrigger={imagePickerTrigger} /> ) @@ -104,6 +120,7 @@ export const MainMinimalTiptapEditor = ({ editor: providedEditor, className, editorContentClassName, + imagePickerTrigger, }: MinimalTiptapProps & { editor: Editor }) => { const { editor } = useTiptapEditor(providedEditor) @@ -121,7 +138,7 @@ export const MainMinimalTiptapEditor = ({ className )} > - +