diff --git a/packages/ui/docs/DEPLOY_INSTRUCTIONS.md b/packages/ui/docs/DEPLOY_INSTRUCTIONS.md deleted file mode 100644 index b40f8246..00000000 --- a/packages/ui/docs/DEPLOY_INSTRUCTIONS.md +++ /dev/null @@ -1,39 +0,0 @@ -# ๐Ÿš€ Deploy Mux Proxy Function - -The credentials are now hardcoded in the function. You need to redeploy it: - -## Option 1: Via Supabase Dashboard (Easiest) - -1. Go to: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/functions -2. Find **mux-proxy** in the functions list -3. Click the **โ‹ฎ** menu (three dots) next to it -4. Select **"Deploy"** or **"Deploy new version"** -5. Upload the updated function files from `supabase/functions/mux-proxy/` - -## Option 2: Via Supabase CLI - -If you have the CLI installed: - -```bash -supabase functions deploy mux-proxy -``` - -## โœ… After Deployment - -1. Go to http://localhost:5173/playground/video-player -2. Sign in -3. Try uploading a video -4. Should work now! ๐ŸŽ‰ - -## โš ๏ธ Important - -The credentials are currently HARDCODED in the function. This is for testing only! - -**Before deploying to production:** -1. Set MUX_TOKEN_ID and MUX_TOKEN_SECRET as Supabase secrets -2. Uncomment the env loading lines in `supabase/functions/mux-proxy/index.ts` -3. Remove the hardcoded values -4. Redeploy - -See `docs/SETUP_MUX_SECRETS.md` for proper setup instructions. - diff --git a/packages/ui/docs/QUICKSTART_MUX.md b/packages/ui/docs/QUICKSTART_MUX.md deleted file mode 100644 index 6db01ab5..00000000 --- a/packages/ui/docs/QUICKSTART_MUX.md +++ /dev/null @@ -1,223 +0,0 @@ -# Mux Video Quick Start Guide - -## ๐ŸŽฏ What You Have Now - -You now have a complete Mux video integration with: - -1. **VideoCard Component** - Display videos with Vidstack player -2. **Mux Uploader Integration** - Upload videos directly to Mux -3. **Video Player Playground** - Test at `/playground/video-player` -4. **Supabase Edge Function** - Secure Mux API proxy - -## โšก Quick Setup (5 minutes) - -### Step 1: Get Mux Credentials - -1. Go to https://dashboard.mux.com/signup -2. After signing up, go to **Settings** โ†’ **Access Tokens** -3. Click **Generate new token** -4. Name it "pm-pics" and enable **Mux Video** permissions -5. Copy the **Token ID** and **Token Secret** - -### Step 2: Configure Supabase Secrets - -You need to add your Mux credentials as secrets to your Supabase project: - -```bash -# Using Supabase CLI -supabase secrets set MUX_TOKEN_ID=your_token_id_here -supabase secrets set MUX_TOKEN_SECRET=your_token_secret_here -``` - -Or via Supabase Dashboard: -1. Go to your Supabase project dashboard -2. Navigate to **Project Settings** โ†’ **Edge Functions** โ†’ **Secrets** -3. Add `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET` - -### Step 3: Deploy the Edge Function - -```bash -supabase functions deploy mux-proxy -``` - -### Step 4: Test It Out - -1. Start your dev server: `npm run dev` -2. Navigate to http://localhost:5173/playground/video-player -3. Sign in (required for uploads) -4. Go to the "Upload Video" tab -5. Drag & drop a video or click to select one -6. Watch it upload, process, and play! - -## ๐Ÿ“ How It Works - -### The Upload Flow - -``` -User Selects Video - โ†“ -Frontend calls /functions/v1/mux-proxy (create-upload) - โ†“ -Edge Function calls Mux API - โ†“ -Returns signed upload URL - โ†“ -MuxUploader uploads video directly to Mux - โ†“ -Video processes on Mux servers - โ†“ -Poll for asset creation - โ†“ -Get playback ID - โ†“ -Play video using Vidstack player -``` - -### Key Concepts - -- **Upload ID**: Temporary ID for tracking the upload -- **Asset ID**: Permanent ID for managing the video in Mux -- **Playback ID**: Public ID used to stream the video - -### URLs You Get - -After uploading, you get these URLs: - -**HLS Stream (for playback):** -``` -https://stream.mux.com/{PLAYBACK_ID}.m3u8 -``` - -**Thumbnail:** -``` -https://image.mux.com/{PLAYBACK_ID}/thumbnail.jpg -``` - -**MP4 Download (if enabled):** -``` -https://stream.mux.com/{PLAYBACK_ID}/high.mp4 -``` - -## ๐Ÿ’พ Save to Database - -The playground has a "Save to Database" button that stores: - -```typescript -{ - user_id: current_user.id, - title: "Video Title", - description: "Description", - video_url: "https://stream.mux.com/{playback_id}.m3u8", - thumbnail_url: "https://image.mux.com/{playback_id}/thumbnail.jpg", - meta: { - mux_asset_id: "asset_abc123", - mux_playback_id: "playback_xyz789" - } -} -``` - -## ๐ŸŽจ Using in Your App - -### Upload Component - -```tsx -import MuxUploader from "@mux/mux-uploader-react"; -import { supabase } from "@/integrations/supabase/client"; - -function MyUploader() { - const fetchUploadUrl = async () => { - const response = await fetch( - `${supabase.supabaseUrl}/functions/v1/mux-proxy`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ action: 'create-upload' }), - } - ); - - const { data } = await response.json(); - return data.url; - }; - - return ( - console.log('Done!', e.detail)} - /> - ); -} -``` - -### Video Player - -```tsx -import VideoCard from "@/components/VideoCard"; - -function MyVideo({ video }) { - return ( - - ); -} -``` - -## ๐Ÿ†“ Pricing - -Mux offers **$20/month in free credits**, which includes: -- ~40 minutes of video encoding -- ~100 hours of video streaming - -Perfect for testing and small projects! - -## ๐Ÿ”ง Troubleshooting - -**Upload button doesn't appear** -- Make sure you're signed in -- Check that edge function is deployed - -**Upload fails** -- Verify Mux credentials are set as Supabase secrets -- Check browser console for errors -- Make sure edge function has correct environment variables - -**Video stuck processing** -- Large videos take time (can be 5-10 minutes for HD) -- Check Mux dashboard: https://dashboard.mux.com -- Look for the asset in the "Assets" section - -**Video won't play** -- Verify the HLS URL format: `https://stream.mux.com/{playback_id}.m3u8` -- Check that playback policy is "public" -- Try the URL directly in your browser - -## ๐Ÿ“š More Info - -See `docs/mux-integration.md` for detailed documentation including: -- Complete API reference -- Webhook setup -- Advanced configuration -- Production best practices - -## ๐ŸŽ‰ You're Done! - -You now have: -- โœ… VideoCard component for displaying videos -- โœ… Mux upload integration -- โœ… Secure API proxy via Edge Functions -- โœ… Video player playground -- โœ… Database storage ready - -Go to `/playground/video-player` and start uploading! ๐ŸŽฌ - diff --git a/packages/ui/docs/SETUP_MUX_SECRETS.md b/packages/ui/docs/SETUP_MUX_SECRETS.md deleted file mode 100644 index a5c4fb66..00000000 --- a/packages/ui/docs/SETUP_MUX_SECRETS.md +++ /dev/null @@ -1,89 +0,0 @@ -# ๐Ÿ” Setup Mux Secrets in Supabase - -## Your Credentials - -``` -MUX_TOKEN_ID: 3ceb1723-1274-48ed-bc1d-0ab967f2dda5 -MUX_TOKEN_SECRET: kYuAFBuOEiA+XZD8qRfgv6rcLVTJWdOLUTrLhiYagVej8UCRdjSzxOAFpvFQJHePcDd/KhqFXcE -``` - -## โšก Quick Setup (2 minutes) - -### Step 1: Open Supabase Dashboard - -Go to: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/settings/functions - -### Step 2: Add Secrets - -Look for **"Secrets"** or **"Environment Variables"** section. - -Click **"New secret"** or **"Add secret"** and add: - -**First Secret:** -- Name: `MUX_TOKEN_ID` -- Value: `3ceb1723-1274-48ed-bc1d-0ab967f2dda5` - -**Second Secret:** -- Name: `MUX_TOKEN_SECRET` -- Value: `kYuAFBuOEiA+XZD8qRfgv6rcLVTJWdOLUTrLhiYagVej8UCRdjSzxOAFpvFQJHePcDd/KhqFXcE` - -### Step 3: Save & Verify - -1. Click **Save** or **Add** -2. You should see both secrets listed (values will be hidden) - -### Step 4: Redeploy Function (if needed) - -If the function still doesn't work after adding secrets: - -1. Go to: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/functions -2. Find **mux-proxy** in the list -3. Click the **โ‹ฎ** menu (three dots) -4. Select **"Redeploy"** or **"Deploy new version"** - -## โœ… Test It - -1. Go to http://localhost:5173/playground/video-player -2. Sign in -3. Try uploading a video -4. Should work now! ๐ŸŽ‰ - -## ๐Ÿ” Troubleshooting - -### Still getting "Mux credentials not configured"? - -**Check #1: Are secrets set?** -- Dashboard โ†’ Settings โ†’ Functions โ†’ Secrets -- You should see `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET` listed - -**Check #2: Is function deployed?** -- Dashboard โ†’ Edge Functions -- `mux-proxy` should show as "Active" or "Deployed" - -**Check #3: Redeploy** -- Sometimes secrets don't update until you redeploy -- Click the โ‹ฎ menu next to mux-proxy โ†’ Redeploy - -**Check #4: Browser console** -- Open DevTools (F12) -- Look for detailed error messages - -### Different error? - -Check the browser console and edge function logs: -- Dashboard โ†’ Edge Functions โ†’ mux-proxy โ†’ Logs - -## ๐Ÿ“ Notes - -- **Local .env file**: Only used for local development, NOT for edge functions -- **Edge function secrets**: Must be set in Supabase Dashboard -- **Security**: Secrets are encrypted and never exposed to the client -- **Updates**: If you change secrets, redeploy the function - -## ๐ŸŽฏ Quick Links - -- Mux Dashboard: https://dashboard.mux.com -- Supabase Project: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip -- Edge Functions: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/functions -- Function Settings: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/settings/functions - diff --git a/packages/ui/docs/agents.md b/packages/ui/docs/agents.md new file mode 100644 index 00000000..cbc4d179 --- /dev/null +++ b/packages/ui/docs/agents.md @@ -0,0 +1,10 @@ +https://github.com/google/adk-samples/blob/main/typescript/agents/customer_service/README.md + +https://github.com/google/adk-js.git + +https://github.com/google/adk-samples + +https://google.github.io/adk-docs/integrations/ +https://google.github.io/adk-docs/integrations/stripe/#available-tools + +https://google.github.io/adk-docs/sessions/session/ diff --git a/packages/ui/docs/cache-ex.md b/packages/ui/docs/cache-ex.md deleted file mode 100644 index e4ff4ac9..00000000 --- a/packages/ui/docs/cache-ex.md +++ /dev/null @@ -1,175 +0,0 @@ -# Cache-EX: Per-Item Cache Invalidation Architecture - -> Replaces the current "nuke everything" AppCache invalidation with surgical, per-item operations. - -## Problem - -A single post title update currently triggers **~50+ cache invalidation events** and SSE broadcasts: -- `flushPostsCache()` โ†’ `appCache.invalidate('posts')` โ†’ cascades to `feed` -- `flushPicturesCache()` โ†’ `appCache.invalidate('pictures')` โ†’ cascades to `posts โ†’ feed`, `pages โ†’ feed` -- Each cascade node emits an SSE event -- Unknown source fires `flushPicturesCache` 9 times (once per picture row) -- Client `StreamInvalidator` receives ~20 SSE events, most for types it doesn't know (`feed`, `system`) - -## Design - -### Core Principle: Separate Cache Clearing from Notification - -| Concern | Current | Cache-EX | -|---|---|---| -| Cache clearing | `invalidate(type)` โ†’ blast everything + SSE + cascade | `invalidate(type)` โ†’ clear cache silently | -| SSE notification | Embedded in cache clearing (N events per cascade) | `notify(type, id, action)` โ†’ 1 explicit SSE per handler | -| Client invalidation | Broad type-based (`invalidateQueries(['posts'])`) | Per-item (`invalidateQueries(['post', id])`) | - -### Server Architecture - -``` -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ Route Handler โ”‚ -โ”‚ handleUpdatePost(id) โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ 1. DB write โ”‚ -โ”‚ 2. invalidate('posts') โ”‚ โ† silent, no SSE -โ”‚ 3. notify('post',id, โ”‚ โ† exactly 1 SSE event -โ”‚ 'update') โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ–ผ SSE -โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” -โ”‚ StreamInvalidator โ”‚ -โ”‚ event: post:abc:update โ”‚ -โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค -โ”‚ invalidateQueries( โ”‚ -โ”‚ ['post', 'abc'] โ”‚ โ† surgical -โ”‚ ) โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -### AppCache Changes - -```ts -// BEFORE: invalidate() does cache clearing + SSE + cascade -invalidate(type: string): Promise - -// AFTER: invalidate() is silent cache clearing only -invalidate(type: string): Promise // clears cache + cascade, NO SSE -notify(type: string, id: string | null, action: 'create' | 'update' | 'delete'): void // 1 SSE -``` - -### Event Shape - -```ts -// BEFORE: { kind:'cache', type:'posts', action:'delete', data:{type:'posts'} } -// AFTER: -interface CacheEvent { - kind: 'cache'; - type: string; // 'post', 'page', 'category', 'picture' - action: 'create' | 'update' | 'delete'; - id: string | null; // entity ID (null = list-level change) - data?: any; // optional payload for optimistic updates -} -``` - -### Client StreamInvalidator - -```ts -// Per-item invalidation with dependency awareness -const INVALIDATION_RULES: Record void> = { - 'post': (id, qc) => { - if (id) qc.invalidateQueries({ queryKey: ['post', id] }); - qc.invalidateQueries({ queryKey: ['posts'] }); - qc.invalidateQueries({ queryKey: ['feed'] }); - }, - 'picture': (id, qc) => { - if (id) qc.invalidateQueries({ queryKey: ['picture', id] }); - qc.invalidateQueries({ queryKey: ['pictures'] }); - }, - 'page': (id, qc) => { - if (id) qc.invalidateQueries({ queryKey: ['page', id] }); - qc.invalidateQueries({ queryKey: ['pages'] }); - }, - 'category': (id, qc) => { - qc.invalidateQueries({ queryKey: ['categories'] }); - }, -}; -``` - ---- - -## Milestones - -### M0: Revert & Baseline โœฆ prerequisite -- [ ] Revert debounce changes from `cache.ts` (return to pre-investigation state) -- [ ] Revert `StreamInvalidator.tsx` changes (remove feed/system mappings, keep logging) - -### M1: Silent Invalidation โœฆ server-only -- [ ] `AppCache.invalidate()` โ†’ remove all `appEvents.emitUpdate()` calls -- [ ] `AppCache.flush()` โ†’ remove SSE emission -- [ ] `AppCache.notify(type, id, action)` โ†’ new method, emits exactly 1 SSE -- [ ] Remove `_isRoot` parameter (no longer needed โ€” no cascade SSE) -- [ ] Keep dependency cascade for cache clearing (still needed server-side) -- [ ] Adjust SSE event shape to include `id` field - -### M2: Handler-Level Notification โœฆ server-only -- [ ] `handleUpdatePost` โ†’ add `appCache.notify('post', postId, 'update')` -- [ ] `handleCreatePost` โ†’ add `appCache.notify('post', newId, 'create')` -- [ ] `handleDeletePost` โ†’ add `appCache.notify('post', postId, 'delete')` -- [ ] `handleUpsertPictures` โ†’ add `appCache.notify('post', postId, 'update')` (pictures belong to a post) -- [ ] `handleUnlinkPictures` โ†’ add `appCache.notify('pictures', null, 'update')` -- [ ] `handleUpdatePicture` โ†’ add `appCache.notify('picture', picId, 'update')` -- [ ] `handleCreatePicture` โ†’ add `appCache.notify('picture', picId, 'create')` -- [ ] `handleDeletePicture` โ†’ add `appCache.notify('picture', picId, 'delete')` -- [ ] Page handlers (`pages-crud.ts`) โ†’ add `appCache.notify('page', pageId, ...)` -- [ ] Category handlers โ†’ add `appCache.notify('category', catId, ...)` -- [ ] Type handlers โ†’ add `appCache.notify('type', typeId, ...)` -- [ ] Layout handlers โ†’ add `appCache.notify('layout', layoutId, ...)` -- [ ] Flush-all handler โ†’ add `appCache.notify('system', null, 'delete')` - -### M3: Client StreamInvalidator โœฆ client-only -- [ ] Replace `EVENT_TO_QUERY_KEY` map with `INVALIDATION_RULES` (function-based) -- [ ] Parse `event.id` from SSE payload -- [ ] Per-item `invalidateQueries` when `id` is present -- [ ] Fallback to list-level invalidation when `id` is null - -### M4: E2E Test โœฆ verification -- [ ] Create `cache-ex.e2e.test.ts` following existing test patterns -- [ ] Test: post update โ†’ SSE emits exactly 1 event (type='post', has id) -- [ ] Test: picture create โ†’ SSE emits exactly 1 event (type='picture', has id) -- [ ] Test: category update โ†’ SSE emits exactly 1 event (type='category', has id) -- [ ] Test: no cascade SSE events (verify `feed`, `pages` events are NOT emitted) -- [ ] Test: cache is still cleared correctly (type + dependents) -- [ ] Test: flush-all โ†’ exactly 1 SSE event (type='system') -- [ ] Add `test:cache-ex` script to `package.json` - ---- - -## Files Changed - -| File | Change | -|---|---| -| `server/src/cache.ts` | Rewrite `invalidate()`, add `notify()`, remove debounce | -| `server/src/events.ts` | Update `AppEvent` interface with `id` field | -| `server/src/products/serving/db/db-posts.ts` | Add `notify()` calls to handlers | -| `server/src/products/serving/db/db-pictures.ts` | Add `notify()` calls to handlers | -| `server/src/products/serving/pages/pages-crud.ts` | Add `notify()` calls to handlers | -| `server/src/products/serving/db/db-categories.ts` | Add `notify()` calls to handlers | -| `server/src/products/serving/db/db-types.ts` | Add `notify()` calls to handlers | -| `server/src/products/serving/index.ts` | Update flush-all handler | -| `src/components/StreamInvalidator.tsx` | Replace map with function-based rules | -| `server/src/products/serving/__tests__/cache-ex.e2e.test.ts` | New test file | -| `server/package.json` | Add `test:cache-ex` script | - -## Verification - -### Automated (E2E) -```bash -cd server -npm run test:cache-ex -``` - -### Manual -1. Start dev servers (`npm run dev` in both client/server) -2. Open browser console, filter for `[StreamInvalidator]` -3. Edit a post title โ†’ save -4. Expected: exactly **1-2 SSE log lines** (`post:xyz:update`), no cascade spam -5. Run `npm run build` in server to verify TypeScript compiles diff --git a/packages/ui/docs/cache.md b/packages/ui/docs/cache.md new file mode 100644 index 00000000..c92261ce --- /dev/null +++ b/packages/ui/docs/cache.md @@ -0,0 +1,61 @@ +# Polymech Cache Invalidation & Mirroring + +This document outlines the architecture for caching data on the Polymech platform, specifically how mutations invalidate Server/Client state and mirror those invalidations to secondary servers (e.g. from local to production instances). + +## Core Mechanisms + +Cache logic splits into: +1. **Server-Side Cache** (`redis` or `memory` via `getCache()`): Used in API routes to hold heavy queries and `pages`. +2. **Client-Side Cache** (`@tanstack/react-query`): The frontend data store holding currently loaded application state. +3. **Server-Sent Events (SSE)**: Event streams connecting the frontend clients to the backend to get real-time cache mutations. +4. **Cache Mirroring**: HTTP Broadcasts mapping local actions outwards to production domains using Service Accounts. + +## Flow of a Cache Invalidation + +When a user modifies data (e.g. Updating a Post) locally: + +1. **Mutation**: A request is sent to an API endpoint (e.g. `PATCH /api/posts/:id`). +2. **Local Invalidation**: + - The handler calls `appCache.invalidate('post')` inside `[server/src/cache.ts](../server/src/cache.ts)`. + - The Cache clears the specific entities and walks the dependency tree (e.g., `post` depends on `pictures` and `categories`). +3. **SSE Broadcast**: + - The handler issues `appCache.notify('post', id, 'update')`. + - The backend pushes an SSE `app-update` down the wire. +4. **Client-Side Reset**: + - The React frontend listens to the stream in `[src/components/StreamInvalidator.tsx](../src/components/StreamInvalidator.tsx)` via `[src/contexts/StreamContext.tsx](../src/contexts/StreamContext.tsx)`. + - Depending on the entity, `StreamInvalidator` resets the specific `post` ID in React Query, and forcefully refetches the overarching `posts`, `pages`, and `feed` caches. +5. **Mirror Broadcast**: + - During `appCache.invalidate()`, if mirroring isn't explicitly disabled, `[server/src/mirror.ts](../server/src/mirror.ts)` takes the `type` and broadcasts it to the domains listed in `.env` under `CACHE_MIRRORS`. + - It acquires a JWT token utilizing `SERVICE_EMAIL` and `SERVICE_PASSWORD` (or `TEST_EMAIL`). + - A `POST` goes out to `https://service.polymech.info/api/cache/invalidate` including `mirror: false` in the payload (to prevent an infinite broadcast loop). +6. **Remote Revalidation**: + - The remote (e.g., Production) instance receives the `invalidate` request at `[server/src/products/serving/index.ts](../server/src/products/serving/index.ts)`. + - It internally invalidates its own Redis/Memory cache utilizing `appCache.invalidate(type, true /* preventMirror */)`. + - It issues an SSE to all *its* connected clients `appCache.notify(type, null, 'update')`, which triggers Step 4 for public visitors! + +## Dependency Graph Configuration + +In `[server/src/cache.ts](../server/src/cache.ts)`, dependencies orchestrate cascade invalidation: + +```typescript +private static DEPENDENCIES: Record = { + 'posts': ['categories', 'pictures'], // Changing a picture/category invalidates posts + 'pages': ['categories', 'pictures', 'translations'], + 'categories': ['types'], + 'translations': [], + 'feed': ['posts', 'pages', 'categories'], + 'auth': [] +}; +``` + +## Adding a New Mirrored Cache Endpoint + +1. If it represents a new domain model, add it to `DEPENDENCIES` in `AppCache`. +2. Add its alias/hook inside `[src/components/StreamInvalidator.tsx](../src/components/StreamInvalidator.tsx)`. + Note: The mirror sends pluralized definitions (`pages`, `types`, `posts`) whereas standard CRUD routes may notify singularly (`page`, `type`, `post`). Map both aliases to the same React Query keys. +3. Utilize `appCache.invalidate('entityName')` inside your API logic. Mirroring happens automatically! + +## Debugging + +Mirroring successes and errors are not logged to `stdout`. All `mirror.ts` and `cache.ts` activities are strictly written to: +`[server/logs/cache.json](../server/logs/cache.json)` using the internal pino `cacheLogger`. \ No newline at end of file diff --git a/packages/ui/docs/caching.md b/packages/ui/docs/caching.md deleted file mode 100644 index af11ecd0..00000000 --- a/packages/ui/docs/caching.md +++ /dev/null @@ -1,49 +0,0 @@ - -# Caching Strategy - -## 1. Server-Side Caching (The Fast Layer) -**Goal**: Reduce DB load by caching public reads (Feeds, Profiles). - -### Cache Adapter Interface -We will use a platform-agnostic interface to support both Memory (Dev/Single-Node) and Redis (Prod/Cluster). - -```typescript -// server/src/commons/cache/types.ts -export interface CacheAdapter { - get(key: string): Promise; - set(key: string, value: T, ttl?: number): Promise; - del(key: string): Promise; - flush(pattern?: string): Promise; -} -``` - -### Implementations -- [ ] **MemoryCache**: Use `lru-cache`. Default for local dev. -- [ ] **RedisCache**: Use `ioredis`. Enabled if `REDIS_URL` is present. - -### usage in `ServingProduct` -- **Feed**: Cache `home-feed` for 60 seconds (Target: [`server/src/products/serving/index.ts`](../server/src/products/serving/index.ts)). -- **Profile**: Cache `profile-{id}` for 5 minutes (Target: [`server/src/products/serving/index.ts`](../server/src/products/serving/index.ts)). Invalidate on profile update webhook. - ---- - -## 2. Client-Side Caching (The Smart Layer) -**Goal**: Eliminate "Double Fetching" and provide instant navigation (Back/Forward). - -### TanStack Query (React Query) -- [ ] **Config**: Set global `staleTime` to 5 minutes for "Content" (Posts, Pictures) in [`src/App.tsx`](../src/App.tsx). -- [ ] **Prefetching**: - - On hover of a User Link, `queryClient.prefetchQuery(['profile', id])`. - - On hover of a Post Card, `queryClient.prefetchQuery(['post', id])`. -- [ ] **Hydration**: - - Use `HydrationBoundary` to ingest `window.__INITIAL_STATE__` served by the optimized Server Injection in [`src/App.tsx`](../src/App.tsx). - -### Optimistic Updates -- [ ] **Likes**: Update UI immediately. Rollback on error in [`src/components/LikeButton.tsx`](../src/components/LikeButton.tsx) (or relevant component). -- [ ] **Edits**: Update Local Cache immediately. Background sync. - ---- - -## 3. CDN & Static Assets -- [ ] Ensure Supabase Storage bucket is behind a CDN (Cloudflare or Supabase built-in). -- [ ] **Thumbnails**: Use the Resizing Proxy (`/api/images/cache/...`) which natively caches processed images on disk/nginx. diff --git a/packages/ui/docs/cad.md b/packages/ui/docs/cad.md new file mode 100644 index 00000000..4703d2ac --- /dev/null +++ b/packages/ui/docs/cad.md @@ -0,0 +1,365 @@ +# PolyMech CAD Tools (pm-cad) + +**Automate your SolidWorks workflow from the command line.** PolyMech CAD Tools (aka **`pm-cad`**) is a Windows-first CLI and Node.js toolkit that **batch-converts SolidWorks assemblies, parts, and drawings** into downstream-friendly formatsโ€”**STEP, PDF, JPG, interactive HTML webviews, XLSX BOMs, and JSON metadata**โ€”without repetitive GUI "Save As" clicks. + +If you build products in SolidWorks and need **repeatable exports for manufacturing, quoting, documentation, release packages, or CI/CD**, `pm-cad` turns CAD deliverables into a scriptable, auditable pipeline. + +> Keywords: SolidWorks CLI, batch convert SolidWorks, SolidWorks STEP export automation, SolidWorks BOM to Excel, eDrawings HTML export, CAD conversion tool, Pack and Go automation, Node.js CAD automation. + +--- + +## Why pm-cad? + +Engineering teams lose hours to manual export routines: opening models, switching configurations, rebuilding, exporting PDFs, STEP files, and BOMsโ€”then doing it again after every change. `pm-cad` replaces all that with **one command** that can process **hundreds of files in parallel**, consistently. + +| Pain point | pm-cad solution | +|---|---| +| Manual "Save As" for every file format | **One command** converts entire folders (glob patterns) into multiple outputs (e.g., `step|pdf|jpg`) | +| No easy way to export BOMs | Generates **XLSX bills of materials** directly from assemblies | +| HTML previews require eDrawings seats | Batch-exports **interactive HTML webviews** via the **eDrawings API** (eDrawings is free) | +| Configuration variants are tedious | Iterates **all configurations automatically** with `${CONFIGURATION}` | +| Hard to integrate into build pipelines | Use it as a **CLI**, **Node.js library**, or **Grunt task** | + +--- + +## What you can do with PolyMech CAD Tools + +### 1) Batch CAD conversion (SolidWorks โ†’ STEP/PDF/JPG) +Export manufacturing and documentation deliverables from **parts, assemblies, and drawings**: + +- **STEP** for CAM, CNC, suppliers, and neutral exchange +- **PDF** for drawings, release packs, and approvals +- **JPG** for thumbnails, product pages, and catalogs + +### 2) Interactive HTML webviews (via eDrawings) +Create **lightweight, shareable HTML previews** for assemblies and partsโ€”ideal for: + +- internal reviews +- sales/support handoffs +- web portals +- vendor communication + +### 3) Excel BOM export (XLSX) +Generate structured **Bills of Materials in Excel** directly from assemblies, with support for: + +- BOM type (Parts Only / Top Level / Indented) +- detail level +- BOM templates +- optional component images + +### 4) Metadata & configuration export (JSON) +Extract rich assembly intelligence for PLM, QA, quoting, and analytics: + +- custom properties (global + per-configuration) +- mass properties, materials, bounding boxes +- suppression state, equations, and more + +### 5) Pack & Go (flatten references) +Collect an assembly and all referenced files into a clean folder for: + +- supplier packages +- archiving +- controlled handoff +- reproducible builds + +--- + +## Quick Start + +```sh +# Install globally +npm i @polymech/cad -g + +# Convert an assembly to STEP + PDF +pm-cad sw --src="./cad/Global*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}.+(step|pdf)" + +# See all options +pm-cad --help +``` + +--- + +## Installation + +### Via npm (recommended) + +```sh +npm i @polymech/cad -g +``` + +### Windows Installer + +Download and run `PolyMechCAD-Setup.exe`. The installer auto-detects privileges: + +- **Double-click** โ†’ installs per-user to `%LOCALAPPDATA%`, no admin required +- **Right-click โ†’ Run as administrator** โ†’ installs system-wide to `Program Files` + +Both modes add `pm-cad` to your **PATH** automatically. + +--- + +## Core features (in detail) + +### Format conversions (glob patterns + multi-output) +Convert between supported formats using glob patterns and multi-extension outputs. + +```sh +# Assembly โ†’ STEP +pm-cad sw --src="./cad/**/*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}.step" + +# Parts + Assemblies โ†’ PDF + JPG (recursive) +pm-cad sw --src="./cad/**/*.+(SLDASM|SLDPRT)" --dst="${SRC_DIR}/${SRC_NAME}.+(pdf|jpg)" + +# Assembly โ†’ Interactive HTML webview (via eDrawings) +pm-cad sw --src="./cad/*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}.html" + +# Draw.io diagrams โ†’ PNG +pm-cad sw --src="./docs/**/*.drawio" --dst="${SRC_DIR}/${SRC_NAME}.png" +``` + +### Bill of Materials (BOM) โ†’ Excel (XLSX) +Extract structured BOMs directly from assemblies. + +```sh +pm-cad sw --src="./cad/**/*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}.xlsx" +``` + +### Metadata & configuration export (JSON) +Turn SolidWorks models into data you can use in automation, dashboards, and audits. + +```sh +# Custom properties โ†’ JSON +pm-cad sw --src="./cad/*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}.json" + +# All configurations โ†’ JSON +pm-cad sw --src="./cad/*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}-configs.json" + +# Per-configuration STEP + HTML export +pm-cad sw --src="./cad/*.SLDASM" --dst="${SRC_DIR}/${SRC_NAME}-${CONFIGURATION}.+(step|html)" +``` + +### Pack & Go (flatten assembly references) + +```sh +pm-cad pack --src="./cad/Global*.SLDASM" --dst="./packed" +``` + +--- + +## Path variables (dynamic, repeatable output paths) + +Build deterministic output paths using built-in variables: + +| Variable | Description | +|---|---| +| `${SRC_DIR}` | Directory of the current source file | +| `${SRC_NAME}` | Base name without extension | +| `${SRC_FILE_EXT}` | Source file extension | +| `${CONFIGURATION}` | Current model configuration name | + +This makes it easy to keep exports adjacent to sources, mirror folder structures, or produce configuration-specific deliverables. + +--- + +## Glob pattern syntax + +| Pattern | Matches | +|---|---| +| `*.SLDASM` | All assemblies in current directory | +| `**/*.SLDPRT` | All parts, recursively | +| `*.+(SLDASM\|SLDPRT)` | Assemblies and parts | +| `*.+(step\|pdf\|jpg)` | Multiple output formats | + +--- + +## Integration options + +### File manager integration (Altap Salamander) +Register `pm-cad` as a custom menu command (F9) for right-click conversions: + +``` +Command: pm-cad +Arguments: sw --src="$(FullName)" --dst="&{SRC_DIR}/&{SRC_NAME}.+(step)" +``` + +> Use `--alt=true` to switch variable prefix from `$` to `&` when the host app uses `$` for its own variables. + +### Batch droplets (drag & drop) +Create a `.bat` file and drag-and-drop files onto it: + +```batch +@echo off +pm-cad sw --src="%~1" --dst="${SRC_DIR}/${SRC_NAME}.+(html)" +``` + +For folders: + +```batch +@echo off +pm-cad sw --src="%~1/**/*.+(SLDASM|SLDPRT)" --dst="${SRC_DIR}/${SRC_NAME}.+(html)" +``` + +### Node.js library (programmatic automation) + +```js +import { convert } from '@polymech/cad/cad/sw-lib'; + +await convert({ + src: './cad/**/*.SLDASM', + dst: '${SRC_DIR}/${SRC_NAME}.+(step|pdf)', + verbose: true, + skip: true +}); +``` + +### Grunt task (build pipeline) + +```js +const cad = require('@polymech/cad/cad/sw-lib'); + +grunt.registerMultiTask('cad-convert', 'Convert SW files', function () { + const done = this.async(); + convert(this.data.items, { ...options }, this.data.output) + .then(() => done()); +}); +``` + +```js +// Gruntfile config +'cad-convert': { + step: { items: products, output: '${SRC_DIR}/${SRC_NAME}.+(step)' }, + html: { items: products, output: '${SRC_DIR}/../resources/${SRC_NAME}.+(html)' }, + bom: { items: products, output: '${SRC_DIR}/../resources/${SRC_NAME}.+(xlsx)' } +} +``` + +--- + +## Supported formats + +### Input formats + +| Format | Extension | +|---|---| +| SolidWorks Assembly | `.SLDASM` | +| SolidWorks Part | `.SLDPRT` | +| SolidWorks Drawing | `.SLDDRW` | +| STEP | `.step`, `.stp` | + +### Output formats + +| Format | Extension | Source | Engine | +|---|---|---|---| +| STEP | `.step` | Parts, Assemblies | `convert.exe` | +| PDF | `.pdf` | Parts, Assemblies, Drawings | `convert.exe` | +| JPEG | `.jpg` | Parts, Assemblies, Drawings | `convert.exe` | +| HTML (eDrawings) | `.html` | Parts, Assemblies | `ExportHTML.exe` | +| JSON (metadata) | `.json` | Assemblies | `model-reader.exe` | +| JSON (configs) | `-configs.json` | Assemblies | `getconfigs.exe` | +| Excel BOM | `.xlsx` | Assemblies | `bom.exe` | + +--- + +## Native toolchain (how it works under the hood) + +`pm-cad` orchestrates a high-performance native toolchain built in C# that talks directly to SolidWorks via **COM interop** (using [xCAD](https://xcad.xarial.com/) + SolidWorks Interop) and to eDrawings via the **eDrawings API**. + +| Binary | Role | +|---|---| +| `convert.exe` | Opens models via COM and calls `SaveAs` for STEP/PDF/JPG. Supports PhotoView 360 ray-trace rendering with configurable quality, resolution, and camera view. Includes Pack & Go support for assembly flattening. | +| `model-reader.exe` | Traverses the full assembly tree and extracts per-component data. Outputs a flat properties table plus a hierarchical `.tree.json`. | +| `bom.exe` | Inserts a BOM table annotation via xCAD, then calls `SaveAsExcel` to produce XLSX output. Supports custom templates, BOM type (PartsOnly / TopLevel / Indented), detail level, and optional component images. | +| `ExportHTML.exe` | Headless eDrawings API exportโ€”opens a hidden WinForms host, loads the model, and saves interactive HTML. No SolidWorks license required (eDrawings is free). | +| `getconfigs.exe` | Enumerates all configurations in a model and serializes configuration names + properties to JSON. | + +### Extracted data (model-reader) +For each component in the assembly tree, `model-reader.exe` can extract: + +| Category | Fields | +|---|---| +| **Custom Properties** | All configuration-specific + global custom properties (evaluated values) | +| **Mass Properties** | Mass, Density, Volume, Surface Area, Center of Mass (X/Y/Z) | +| **Materials** | Material name + database per part | +| **Bounding Box** | Min/Max X/Y/Z per component | +| **Equations** | All equation names and evaluated values | +| **Model States** | What's Wrong count, error codes, warning flags, affected feature types | +| **Suppression** | Component suppression state | + +--- + +## CLI options reference + +### Rendering + +| Flag | Default | Description | +|---|---|---| +| `--renderer` | `solidworks` | Render engine (`solidworks` or `photoview`) | +| `--quality` | `2` (Good) | Ray trace quality level | +| `--width` | `1024` | Output image width in pixels | +| `--height` | `1024` | Output image height in pixels | +| `--view` | `Render` | Camera view name | +| `--configuration` | `Default` | Model configuration to use | + +### BOM options + +| Flag | Default | Description | +|---|---|---| +| `--bom-config` | `Default` | BOM configuration | +| `--bom-type` | `2` | BOM type | +| `--bom-detail` | `1` | Detail level | +| `--bom-template` | โ€” | Custom BOM table template | +| `--bom-images` | `false` | Include component images | + +### Workflow + +| Flag | Description | +|---|---| +| `--dry` | Preview operations without executing | +| `--cache` | Use file hash caching to skip unchanged files | +| `--save` | Save the model after processing | +| `--rebuild` | Force model rebuild before export | +| `--pack` | Pack and go mode | +| `--light` | Lightweight mode | +| `--close` | Close SolidWorks after each conversion | +| `--alt` | Use `&` instead of `$` for variable prefix | +| `--logLevel` | Minimum log level (`debug`, `info`, `warn`, `error`) | + +--- + +## Requirements + +- **Node.js** 18+ +- **SolidWorks** 2020โ€“2025 (auto-detected) +- **Windows** (SolidWorks COM interop) + +--- + +## Who pm-cad is for (common use cases) + +- **Manufacturing handoffs:** Export STEP + drawing PDFs for suppliers in one repeatable step. +- **Engineering release:** Generate a "release pack" folder with consistent naming and formats. +- **Sales/Support enablement:** Produce interactive **HTML previews** that anyone can open in a browser. +- **Automation & CI:** Create nightly exports (STEP/PDF/BOM) whenever CAD changes. +- **Data extraction:** Pull custom properties and mass/material data into JSON for quoting or audits. + +--- + +## FAQ + +### Does HTML export require a SolidWorks license? +No. HTML export is performed via the **eDrawings API**. eDrawings is free; `pm-cad` uses a headless host to export interactive HTML. + +### Can I export all configurations automatically? +Yes. Use `${CONFIGURATION}` in the destination pattern and `pm-cad` will iterate configurations. + +### Can I keep exports next to source files with matching names? +Yes. Use `${SRC_DIR}` and `${SRC_NAME}` for deterministic output paths. + +### Is it safe to run on large assemblies? +`pm-cad` is designed for batch processing and supports workflow controls like caching (`--cache`), rebuild control (`--rebuild`), lightweight mode (`--light`), and closing SolidWorks between jobs (`--close`) depending on your stability/performance needs. + +--- + +## Summary + +**PolyMech CAD Tools** brings modern automation to SolidWorks deliverables: **batch exports**, **BOM to Excel**, **interactive HTML previews**, **metadata extraction**, and **Pack & Go**โ€”all from a **scriptable CLI** or **Node.js API**. Standardize your exports, eliminate manual steps, and make CAD output a reliable part of your engineering pipeline. diff --git a/packages/ui/docs/campaigns.md b/packages/ui/docs/campaigns.md new file mode 100644 index 00000000..496045c2 --- /dev/null +++ b/packages/ui/docs/campaigns.md @@ -0,0 +1,117 @@ +# Campaigns + +Email campaign management โ€” create campaigns, pick an email page template, target contact groups, and track delivery. + +## Architecture Overview + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Frontend โ”‚ +โ”‚ CampaignsManager.tsx โ”‚ +โ”‚ (MUI DataGrid, PagePicker, GroupPicker) โ”‚ +โ”‚ โ”‚ +โ”‚ client-campaigns.ts โ”‚ +โ”‚ (fetch wrappers, bearer token) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ /api/campaigns/* + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ€“ CampaignsProduct โ”‚ +โ”‚ products/campaigns/index.ts โ”‚ +โ”‚ products/campaigns/routes.ts โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ Supabase + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Tables โ”‚ +โ”‚ campaigns โ”‚ +โ”‚ (+ marketing_emails for sends) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Database + +### `campaigns` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid | PK, auto-generated | +| `owner_id` | uuid | FK โ†’ `auth.users`, not null | +| `name` | text | Campaign label, not null | +| `page_slug` | text | Page slug to render as email | +| `page_id` | text | Optional page UUID | +| `subject` | text | Email subject line | +| `group_ids` | uuid[] | Target contact group IDs | +| `lang` | text | Language tag (`en`, `de`, โ€ฆ) | +| `tracking_id` | text | Tracking param injected into URLs | +| `vars` | jsonb | Template variables (`--var-*` equivalent) | +| `status` | text | `draft` / `scheduled` / `sending` / `sent` / `failed` | +| `stats` | jsonb | `{ total, sent, failed, skipped }` | +| `scheduled_at` | timestamptz | When to send (null = manual) | +| `started_at` | timestamptz | When sending began | +| `completed_at` | timestamptz | When sending finished | +| `meta` | jsonb | Arbitrary metadata | +| `created_at` | timestamptz | โ€” | +| `updated_at` | timestamptz | Auto-updated via trigger | + +**Indexes:** `owner_id`, `status`, `page_slug` + +### RLS + +- **Owners**: full CRUD on their own rows (`owner_id = auth.uid()`) +- **Admins** (`user_roles.role = 'admin'`): full access to all rows + +## Server API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/api/campaigns` | Auth | List campaigns. Query: `?status=&q=&limit=&offset=` | +| `POST` | `/api/campaigns` | Auth | Create campaign | +| `GET` | `/api/campaigns/:id` | Auth | Get single campaign | +| `PATCH` | `/api/campaigns/:id` | Auth | Update campaign (partial) | +| `DELETE` | `/api/campaigns/:id` | Auth | Delete campaign | + +## Frontend Client + +`src/modules/campaigns/client-campaigns.ts` โ€” all functions inject Supabase bearer token automatically. + +```ts +fetchCampaigns(options?) โ†’ Campaign[] // options: { status?, q?, limit?, offset? } +getCampaign(id) โ†’ Campaign +createCampaign(data) โ†’ Campaign +updateCampaign(id, data) โ†’ Campaign +deleteCampaign(id) โ†’ void +``` + +## Frontend UI โ€” `CampaignsManager` + +Mounted at `/user/:id/campaigns` via `UserProfile.tsx`. + +| Feature | Detail | +|---------|--------| +| **DataGrid** | Columns: name, page, status, groups, stats, created_at, actions | +| **URL state sync** | Filter, sort, visibility, pagination persisted in search params | +| **Toolbar** | Search, status filter, "New Campaign" button | +| **Campaign dialog** | Name, subject, language, tracking ID, PagePickerDialog for email page, ContactsPicker for groups | +| **Status workflow** | `draft` โ†’ `scheduled` โ†’ `sending` โ†’ `sent` / `failed` | + +## Relationship to Email System + +The `campaigns` table is the **parent** that defines what to send and to whom. The existing `marketing_emails` table continues to track **individual sends** per recipient. A future "Send Campaign" action will resolve group members, render the email page, and use the email tracking flow (`POST /api/email/track` โ†’ send โ†’ `PATCH /api/email/track/:id`) โ€” the same pipeline currently used by the CLI `email-send` command. + +## Environment Variables + +Inherits same Supabase env as the rest of the server โ€” no additional variables required. + +## Source Files + +| File | Description | +|------|-------------| +| [campaigns.md](campaigns.md) | This document | +| [migration](../supabase/migrations/20260306130000_create_campaigns.sql) | DB schema, RLS, indexes | +| [routes.ts](../server/src/products/campaigns/routes.ts) | Zod-OpenAPI route definitions | +| [index.ts](../server/src/products/campaigns/index.ts) | CampaignsProduct handlers | +| [client-campaigns.ts](../src/modules/campaigns/client-campaigns.ts) | Frontend fetch wrappers | +| [CampaignsManager.tsx](../src/components/CampaignsManager.tsx) | Main UI component (DataGrid, dialogs) | +| [ContactsPicker.tsx](../src/components/ContactsPicker.tsx) | Group selection component | +| [PagePickerDialog.tsx](../src/modules/pages/PagePickerDialog.tsx) | Page selection dialog | diff --git a/packages/ui/docs/cats-acl.md b/packages/ui/docs/cats-acl.md new file mode 100644 index 00000000..2fb2d451 --- /dev/null +++ b/packages/ui/docs/cats-acl.md @@ -0,0 +1,179 @@ +# Category ACL โ€” Design & Implementation Plan + +## Overview + +Per-category access control โ€” reusing the existing **resource-agnostic ACL system** (`resource_acl` table + `IAclBackend` + `AclEditor`). + +**Roles:** `admin`, `authenticated` (logged-in users), `anonymous` +**Phase 1:** Only admins can manage category permissions +**Phase 2:** Users can create categories and manage permissions for their own categories + +--- + +## โœ… Completed + +### Backend registration + +`serving/index.ts` โ€” registered the `'category'` ACL backend: + +```ts +import { DbAclBackend } from './db/db-acl-db.js'; +registerAclBackend('category', new DbAclBackend('category')); +``` + +This enables the full ACL API for categories: +- `GET /api/acl/category/:categoryId` โ€” read ACL +- `POST /api/acl/category/:categoryId/grant` โ€” grant permission +- `POST /api/acl/category/:categoryId/revoke` โ€” revoke permission + +### Virtual user ID fix + +`db-acl-db.ts` โ€” fixed `DbAclBackend` to handle virtual/sentinel user IDs (`anonymous`, `authenticated`). These aren't valid UUIDs and can't be stored in the `user_id` FK column, so they're transparently mapped to/from `group_name`: + +| AclEntry.userId | DB column | Value | +|-----------------|-----------|-------| +| `anonymous` | `group_name` | `'anonymous'` | +| `authenticated` | `group_name` | `'authenticated'` | +| `` | `user_id` | UUID FK โ†’ auth.users | +| _(group grant)_ | `group_name` | e.g. `'editors'` | + +### E2E tests + +`category-acl.e2e.test.ts` โ€” 19 tests, all passing: + +| Group | Tests | Coverage | +|-------|-------|----------| +| ACL CRUD | 9 | grant anon/auth/user, verify reads, upsert, full revoke cycle | +| Access control | 4 | unauth rejection, 404 missing revoke, input validation | +| Multi-user | 3 | regular user denied ACL management | +| Group grants | 2 | grant/revoke by group name | +| ACL isolation | 1 | grants don't leak between categories | + +Run: `npm run test:acl:categories` + +--- + +## ๐Ÿ”ฒ Next Steps โ€” CategoryManager UI Integration + +### Goal + +Add a **Permissions** section to [CategoryManager.tsx](../src/components/widgets/CategoryManager.tsx)'s right panel, using the existing [AclEditor](../src/components/admin/AclEditor.tsx) component. + +### Where it goes + +The right panel in CategoryManager has 3 states: + +``` +editingCategory? โ†’ Edit form (name, slug, visibility, variables, types, translate) +selectedCategoryId? โ†’ Selected view (name, description, page link actions) โ† ADD HERE +else โ†’ Empty state ("Select a category...") +``` + +Add the `AclEditor` inside the **selected view** (state 2), below the "Current Page Link" section (around line 548): + +### Implementation + +```tsx +// 1. Import +import { AclEditor } from '@/components/admin/AclEditor'; + +// 2. Add to the right panel, after the page-link section (~line 548) +// Inside the `selectedCategoryId ? (...)` branch: + +
+ + +
+``` + +The `AclEditor` already handles: +- Anonymous toggle with permission picker +- Authenticated toggle with permission picker +- Per-user grants via `UserPicker` +- Active permissions table with revoke buttons + +### Props mapping + +| AclEditor prop | StorageManager (VFS) | CategoryManager | +|---------------|---------------------|-----------------| +| `resourceType` | `'vfs'` (default) | `'category'` | +| `mount` | mount name (`'home'`) | category UUID | +| `path` | folder path (`'/shared'`) | always `'/'` | + +### Files to change + +| File | Change | +|------|--------| +| [CategoryManager.tsx](../src/components/widgets/CategoryManager.tsx) | Import `AclEditor`, add it to selected-category view | + +No other files need modification โ€” the backend, API, and ACL editor component are all ready. + +--- + +## Architecture Reference + +``` +โ”Œโ”€โ”€ Supabase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ resource_acl table (RLS-enabled) โ”‚ +โ”‚ โ”Œโ”€ resource_type: 'category' โ”‚ +โ”‚ โ”œโ”€ resource_id: category UUID โ”‚ +โ”‚ โ”œโ”€ user_id / group_name โ”‚ +โ”‚ โ”œโ”€ permissions[] โ”‚ +โ”‚ โ””โ”€ path: '/' โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ฒ +โ”Œโ”€โ”€ Server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ db-acl.ts IAclBackend โ”‚ +โ”‚ โ”œ registerAclBackend('vfs', ...) โ”‚ +โ”‚ โ”” registerAclBackend('category',โ€ฆ) โ”‚ โœ… +โ”‚ db-acl-db.ts DbAclBackend โ”‚ โœ… virtual ID fix +โ”‚ db-categories.ts CRUD + cache โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ฒ +โ”Œโ”€โ”€ Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ client-acl.ts fetch/grant/revokeโ”‚ reuse as-is +โ”‚ AclEditor.tsx UI โ”‚ reuse as-is +โ”‚ CategoryManager + ACL section โ”‚ ๐Ÿ”ฒ next step +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Permission Model + +| Permission | Meaning | +|------------|---------| +| `read` | View category and its items | +| `list` | Browse category in tree navigation | +| `write` | Add/remove items from category | +| `manage` | Edit category metadata | +| `admin` | Full control including ACL | + +### Default Behavior + +Categories default to their `visibility` field (`public`/`unlisted`/`private`). ACL entries override visibility for fine-grained control. + +### Phase 2 โ€” Future + +| Task | Description | +|------|-------------| +| ACL-filtered category reads | Filter `fetchCategoriesServer` by caller's `list` permission | +| ACL-filtered tree view | Hide restricted categories in `CategoryTreeView.tsx` | +| User-owned category ACL mgmt | Let users manage permissions on categories they own | + +## File Reference + +| File | Role | Status | +|------|------|--------| +| [resource_acl.sql](../supabase/migrations/20260219215000_create_resource_acl.sql) | DB schema | โœ… No changes needed | +| [db-acl.ts](../server/src/products/serving/db/db-acl.ts) | ACL orchestrator | โœ… No changes needed | +| [db-acl-db.ts](../server/src/products/serving/db/db-acl-db.ts) | Supabase backend | โœ… Virtual ID fix | +| [index.ts](../server/src/products/serving/index.ts) | Backend registration | โœ… Category registered | +| [category-acl.e2e.test.ts](../server/src/products/serving/__tests__/category-acl.e2e.test.ts) | E2E tests | โœ… 19/19 pass | +| [CategoryManager.tsx](../src/components/widgets/CategoryManager.tsx) | Category UI | ๐Ÿ”ฒ Add AclEditor | +| [client-acl.ts](../src/modules/user/client-acl.ts) | Client ACL API | โœ… No changes needed | +| [AclEditor.tsx](../src/components/admin/AclEditor.tsx) | ACL UI component | โœ… No changes needed | diff --git a/packages/ui/docs/chat-module.md b/packages/ui/docs/chat-module.md new file mode 100644 index 00000000..565b5850 --- /dev/null +++ b/packages/ui/docs/chat-module.md @@ -0,0 +1,457 @@ +# Chat Module โ€” Architecture & Developer Documentation + +> **Location:** [`src/modules/ai/`](../src/modules/ai/) +> **Page:** [`src/pages/PlaygroundChat.tsx`](../src/pages/PlaygroundChat.tsx) + +--- + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Component Tree](#component-tree) +- [ChatPanel โ€” Reusable Component](#chatpanel--reusable-component) +- [Message Flow](#message-flow) +- [Tool-Calling Flow](#tool-calling-flow) +- [Session Management](#session-management) +- [Features](#features) +- [File Browser Integration](#file-browser-integration) +- [External Context & Tools Injection](#external-context--tools-injection) +- [Tool System](#tool-system) +- [Storage](#storage) +- [File Reference](#file-reference) + +--- + +## Overview + +The chat module is a client-side AI playground built on the **OpenAI SDK** (used for both OpenAI and OpenRouter providers). It supports multi-turn conversations, image attachments, file context attachments, streaming responses, tool calling (search, page creation, image generation, file system), session persistence, and full export capabilities. + +The core is a **reusable `ChatPanel` component** ([`ChatPanel.tsx`](../src/modules/ai/ChatPanel.tsx)) with preset modes (`simple`, `standard`, `developer`), embeddable anywhere. All state and logic lives in [`useChatEngine`](../src/modules/ai/useChatEngine.ts). The playground page ([`PlaygroundChat.tsx`](../src/pages/PlaygroundChat.tsx)) is a thin wrapper using the `developer` preset. + +--- + +## Architecture + +```mermaid +graph TB + subgraph ChatPanelComp["ChatPanel.tsx (reusable)"] + Header["ChatHeader"] + RPG["ResizablePanelGroup"] + subgraph Sidebar["ChatSidebar (ResizablePanel)"] + Sessions["Sessions"] + Provider["Provider & Model"] + SysPrompt["System Prompt"] + Tools["Tools Toggles"] + FileBrowser["Files (FileBrowserPanel)"] + Stats["Stats"] + Payload["Prompt Payload (CompactTreeView)"] + Logs["Chat Logs (ChatLogBrowser)"] + end + subgraph Main["ChatMessages (ResizablePanel)"] + Messages["MessageBubble ร— N"] + Composer["ChatComposer + FileContext chips"] + end + end + + subgraph Engine["useChatEngine Hook"] + State["State Management"] + API["API Client (OpenAI SDK)"] + ToolPreset["Tool Presets"] + ExtTools["extraToolsRef (external)"] + CtxProvider["contextProviderRef (external)"] + FileCtx["FileContext State"] + SessionMgr["Session Storage"] + Export["Export Handlers"] + end + + ChatPanelComp --> Engine + API --> OpenAI["OpenAI API"] + API --> OpenRouter["OpenRouter API"] + ToolPreset --> SearchTools["searchTools.ts"] + ToolPreset --> ImageTools["imageTools.ts"] + ToolPreset --> VfsTools["vfsTools.ts"] + ToolPreset --> PageTools["pageTools.ts"] + SessionMgr --> LocalStorage["localStorage"] +``` + +--- + +## Component Tree + +| Component | File | Role | +|-----------|------|------| +| **PlaygroundChat** | [`PlaygroundChat.tsx`](../src/pages/PlaygroundChat.tsx) | Thin page wrapper: `` | +| **ChatPanel** | [`ChatPanel.tsx`](../src/modules/ai/ChatPanel.tsx) | Reusable layout: header + sidebar + messages, configurable via presets and props | +| โ†ณ **ChatHeader** | [`components/ChatHeader.tsx`](../src/modules/ai/components/ChatHeader.tsx) | Top bar: provider badge, New/JSON/MD/Clear/Settings buttons | +| โ†ณ **ChatSidebar** | [`components/ChatSidebar.tsx`](../src/modules/ai/components/ChatSidebar.tsx) | Collapsible settings: sessions, provider, system prompt, tools, **file browser**, stats, payload inspector, logs | +| โ†ณ **FileBrowserPanel** | [`FileBrowserPanel.tsx`](../src/apps/filebrowser/FileBrowserPanel.tsx) | VFS file browser (home mount, list view) with file selection โ†’ attach as context | +| โ†ณ **ChatMessages** | (in ChatPanel.tsx) | Messages area + composer wrapper | +| โ†ณ **MessageBubble** | [`components/MessageBubble.tsx`](../src/modules/ai/components/MessageBubble.tsx) | Single message: avatar, copy-to-clipboard, markdown rendering, streaming indicator | +| โ†ณ **ChatComposer** | [`components/ChatComposer.tsx`](../src/modules/ai/components/ChatComposer.tsx) | Textarea input, attachments, drag-drop, image picker, **file context chips**, prompt history, send/cancel | +| โ†ณ **CompactTreeView** | [`ChatLogBrowser.tsx`](../src/components/ChatLogBrowser.tsx) | Keyboard-navigable JSON tree browser (shared by Payload and Logs) | +| โ†ณ **ChatLogBrowser** | [`ChatLogBrowser.tsx`](../src/components/ChatLogBrowser.tsx) | Log viewer with level filtering and drill-in on data objects | + +--- + +## ChatPanel โ€” Reusable Component + +[`ChatPanel.tsx`](../src/modules/ai/ChatPanel.tsx) is the primary entry point for embedding chat anywhere. + +### Presets + +| Preset | Header | Sidebar | Sidebar open by default | +|-----------|--------|---------|-------------------------| +| `simple` | โŒ | โŒ | โ€” | +| `standard` | โœ… | โœ… | closed | +| `developer` | โœ… | โœ… | open | + +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `preset` | `'simple' \| 'standard' \| 'developer'` | Layout preset (default: `'developer'`) | +| `showHeader` | `boolean?` | Override header visibility | +| `showSidebar` | `boolean?` | Override sidebar availability | +| `sidebarOpen` | `boolean?` | Override sidebar initial state | +| `className` | `string?` | CSS class on outer container | +| `layoutId` | `string?` | Persistence ID for panel sizes (default: `'chat-layout'`) | +| `getContext` | `() => string \| null` | Dynamic context injected into system prompt per-send | +| `extraTools` | `() => any[]` | Extra tools added to tool-calling payload per-send | + +### Usage + +```tsx +import ChatPanel from '@/modules/ai/ChatPanel'; + +// Full developer experience (default) + + +// Embeddable minimal chat + + +// With external context and tools + `Active page: ${slug}\nSelection: ${ids.join(', ')}`} + extraTools={() => [myCustomTool]} +/> +``` + +### Exports + +| Export | Description | +|--------|-------------| +| `ChatPanel` (default) | Full layout with header + sidebar + messages | +| `ChatMessages` | Just the messages + composer (for custom layouts) | +| `ChatPreset` | Type: `'simple' \| 'standard' \| 'developer'` | +| `ChatPanelProps` | Props interface | + +--- + +## Message Flow + +```mermaid +sequenceDiagram + participant U as User + participant C as ChatComposer + participant E as useChatEngine + participant A as OpenAI API + + U->>C: Types message + optional images + U->>C: Press Enter + C->>E: sendMessage() + E->>E: Create userMsg + empty assistantMsg + E->>E: setMessages([...prev, user, assistant]) + E->>E: Build apiMessages[] from systemPrompt + history + E->>A: chat.completions.create (stream: true) + loop Streaming + A-->>E: delta chunks + E->>E: Append to assistantMsg.content + E->>E: setMessages() (triggers re-render) + end + A-->>E: Stream complete + E->>E: Mark assistantMsg.isStreaming = false + E->>E: Auto-save session to localStorage +``` + +### Cancellation + +The user can cancel an in-progress response via the **Stop** button in the composer or the streaming indicator in the message bubble. This triggers `handleCancel()`, which calls `abortRef.current?.abort()` to abort the fetch and marks the current assistant message as complete. + +--- + +## Tool-Calling Flow + +When **tools are enabled**, the engine uses `runTools()` from the OpenAI SDK instead of `create()`: + +```mermaid +sequenceDiagram + participant E as useChatEngine + participant A as OpenAI API + participant T as Tool Functions + + E->>A: runTools(apiMessages, tools[]) + loop Tool Calls + A-->>E: tool_call request (e.g. search_content) + E->>T: Execute tool function + T-->>E: JSON result + E->>E: Append tool message to context + E->>E: addChatLog() for verbose logging + E->>A: Continue with tool result + end + A-->>E: Final assistant response (streamed) + E->>E: Merge toolContext into assistant message + E->>E: Stream content to UI +``` + +### Tool Context Persistence + +Tool call results are summarized and attached to the assistant message as `toolContext`. This context is included in subsequent API calls, preventing the AI from re-searching the same data in follow-up questions. + +--- + +## Session Management + +```mermaid +sequenceDiagram + participant E as useChatEngine + participant S as chatSessions.ts + participant LS as localStorage + + Note over E: On every message change + E->>S: saveSession({ id, title, messages }) + S->>LS: setItem("chat-session-{id}", full data) + S->>LS: setItem("chat-sessions-index", metadata[]) + S->>S: Trim to MAX_SESSIONS (50) + + Note over E: Load session + E->>S: loadSession(id) + S->>LS: getItem("chat-session-{id}") + S-->>E: ChatSession (sanitized) + E->>E: setMessages(clean), setSessionId(id) +``` + +--- + +## Features + +### Multi-Provider Support +- **OpenAI** โ€” via proxy or direct API key +- **OpenRouter** โ€” custom `baseURL` + API key +- Provider/model selection persisted in localStorage +- API keys stored in Supabase user secrets + +### Image Attachments +- **Drag & drop** files onto the composer +- **Paste** images from clipboard +- **File picker** (file input dialog) +- **Gallery picker** โ€” browse platform images via `ImagePickerDialog` +- Remote images proxied via server render API for resizing (`getResizedImageUrl`) + +### Streaming Responses +- Uses `stream: true` with the OpenAI SDK +- Real-time content rendering via `isStreaming` flag on assistant messages +- Auto-scroll to bottom during streaming + +### Prompt History +- All sent prompts saved to localStorage via `usePromptHistory` +- Navigate with **Ctrl+โ†‘ / Ctrl+โ†“** in the composer +- History persisted under key `promptHistoryChat` + +### Export +| Format | Action | Method | +|--------|--------|--------| +| **JSON** | Download `.json` file + copy to clipboard | [`exportChatAsJson`](../src/modules/ai/chatExport.ts) | +| **Markdown** | Copy to clipboard | [`exportChatAsMarkdown`](../src/modules/ai/chatExport.ts) | +| **Payload JSON** | Copy button in sidebar | `CompactTreeView` headerContent | +| **Logs JSON** | Copy button in sidebar | `ChatSidebar` headerContent | + +### Sidebar Inspector +- **Prompt Payload** โ€” live `useMemo` of the API messages array, browseable via `CompactTreeView` with keyboard navigation (โ†‘โ†“โ†โ†’, search, breadcrumbs) +- **Chat Logs** โ€” verbose timestamped log of all engine events (tool calls, results, errors) +- Both sections support **copy-to-clipboard** via header buttons + +### Desktop/Mobile Layout +- **Desktop** โ€” `ResizablePanelGroup` (sidebar 25% default, 15โ€“45% range; `autoSaveId="chat-layout"` for persistence) +- **Mobile** โ€” sidebar as overlay (85vw, max 360px) with backdrop; full-width chat panel + +--- + +## File Browser Integration + +The sidebar includes a **Files** collapsible section embedding a [`FileBrowserPanel`](../src/apps/filebrowser/FileBrowserPanel.tsx) configured for the user's `home` VFS mount. + +### File Context Workflow + +1. **Browse** โ€” navigate your home drive in the sidebar file browser +2. **Preview** โ€” double-click / Enter / Space opens the lightbox (image, video, or text) +3. **Attach** โ€” click a file, then click the **Attach** button to add it as context +4. **View** โ€” attached files appear as **teal chips** above the composer and listed in the Files section footer +5. **Inject** โ€” file path + content is injected into the system prompt under `--- Attached Files (editable via fs_write) ---` +6. **Write back** โ€” the LLM can modify attached files via the existing `fs_write` tool +7. **Remove** โ€” click the ร— on any chip or in the sidebar list to detach + +### FileContext Type + +```typescript +interface FileContext { + path: string; // VFS path (e.g. "notes/readme.md") + mount: string; // VFS mount (e.g. "home") + name: string; // filename only + content: string; // file text content +} +``` + +--- + +## External Context & Tools Injection + +Consumers can inject dynamic context and custom tools into the chat engine via `ChatPanel` props, enabling domain-specific integrations without modifying the core chat module. + +### `getContext` โ€” Dynamic System Prompt Injection + +Called synchronously in `sendMessage()` just before building the API payload. The returned string is appended to the system prompt for that specific request. + +```tsx + { + const sel = getSelectedItems(); + return sel.length ? `Selected items:\n${sel.map(s => `- ${s.name}`).join('\n')}` : null; + }} +/> +``` + +### `extraTools` โ€” Custom Tool Injection + +Called when assembling the tools array for each send. Return OpenAI-compatible `RunnableToolFunctionWithParse` definitions. + +```tsx + [ + { + type: 'function', + function: { + name: 'update_product', + parse: JSON.parse, + description: 'Update a product field', + parameters: { type: 'object', properties: { id: { type: 'string' } } }, + function: async (args) => { /* ... */ }, + }, + }, + ]} +/> +``` + +### Implementation + +Both use **ref-based injection** in `useChatEngine`: +- `contextProviderRef` โ€” `React.MutableRefObject<(() => string | null) | null>` +- `extraToolsRef` โ€” `React.MutableRefObject<(() => any[]) | null>` + +`ChatPanel` wires its props into these refs via `useEffect`, ensuring the engine always has the latest provider functions without prop-drilling into the hook. + +## Tool System + +All tools follow the OpenAI SDK `RunnableToolFunctionWithParse` interface (Zod parse + async function + JSON schema). + +### Search Tools โ€” [`searchTools.ts`](../src/modules/ai/searchTools.ts) + +| Tool | Description | +|------|-------------| +| `search_content` | Full-text search across pages, posts, pictures (type filter, limit) | +| `find_pages` | Search pages only | +| `find_pictures` | Search pictures only | +| `get_page_content` | Fetch full page content by user_id + slug | +| `list_categories` | List all category trees | +| `find_by_category` | Find items by category slug | + +Bundled via `createSearchToolPreset()`. + +### Image Tools โ€” [`imageTools.ts`](../src/modules/ai/imageTools.ts) + +| Tool | Description | +|------|-------------| +| `generate_image` | Generate image from prompt โ†’ upload to Supabase `temp-images` bucket โ†’ return markdown embed | + +### VFS Tools โ€” [`vfsTools.ts`](../src/modules/ai/vfsTools.ts) + +| Tool | Description | +|------|-------------| +| `vfs_ls` | List directory contents (optional glob filter) | +| `vfs_read` | Read file content | +| `vfs_write` | Write/create file | +| `vfs_mkdir` | Create directory | +| `vfs_delete` | Delete file or directory | + +Operates on the user's home drive via authenticated API calls. + +### Page Tools โ€” [`lib/pageTools.ts`](../src/lib/pageTools.ts) + +| Tool | Description | +|------|-------------| +| `create_page` | Create a new page on the user's account | + +### Tool Toggle Controls + +Each tool category has an independent toggle in the sidebar: + +| Toggle | localStorage Key | Default | +|--------|-----------------|---------| +| Search Tools | `chat-settings-tools` | `true` | +| Page Tools | `chat-settings-page-tools` | `true` | +| Image Tools | `chat-settings-image-tools` | `false` | +| File Tools | `chat-settings-vfs-tools` | `false` | + +--- + +## Storage + +All persistence is **client-side via `localStorage`**. No server database is used for chat data. + +### localStorage Keys + +| Key | Type | Description | +|-----|------|-------------| +| `chat-sessions-index` | `Omit[]` | Session metadata index (max 50) | +| `chat-session-{uuid}` | `ChatSession` | Full session data (messages + metadata) | +| `chat-settings-provider` | `string` | Selected AI provider | +| `chat-settings-model` | `string` | Selected model | +| `chat-settings-system-prompt` | `string` | System prompt text | +| `chat-settings-tools` | `boolean` | Search tools toggle | +| `chat-settings-page-tools` | `boolean` | Page tools toggle | +| `chat-settings-image-tools` | `boolean` | Image tools toggle | +| `chat-settings-vfs-tools` | `boolean` | VFS tools toggle | +| `chat-settings-show` | `boolean` | Sidebar visibility | +| `chat-settings-sidebar-width` | `number` | (legacy โ€” now managed by ResizablePanelGroup) | +| `chat-layout` | (react-resizable-panels) | Panel sizes (auto-managed) | +| `promptHistoryChat` | `string[]` | Prompt history ring buffer | +| `chat-section-*` | `boolean` | CollapsibleSection open/closed states | + +### Session Lifecycle + +1. **Auto-save** โ€” sessions save on every message change via `useEffect` +2. **Title generation** โ€” first 60 chars of the first user message +3. **Max sessions** โ€” oldest sessions pruned when exceeding 50 +4. **Sanitization** โ€” streaming flags stripped, empty orphan messages filtered on save and load + +--- + +## File Reference + +| File | Purpose | +|------|---------| +| [`ChatPanel.tsx`](../src/modules/ai/ChatPanel.tsx) | **Reusable component**: presets, layout, external context/tools injection | +| [`useChatEngine.ts`](../src/modules/ai/useChatEngine.ts) | Central hook: all state, API calls, streaming, tool orchestration, file contexts | +| [`types.ts`](../src/modules/ai/types.ts) | `ChatMessage`, `ImageAttachment`, `FileContext`, helpers | +| [`chatSessions.ts`](../src/modules/ai/chatSessions.ts) | Session persistence (localStorage) | +| [`chatExport.ts`](../src/modules/ai/chatExport.ts) | JSON/Markdown export (download + clipboard) | +| [`searchTools.ts`](../src/modules/ai/searchTools.ts) | 6 search/content tools + `createSearchToolPreset` | +| [`imageTools.ts`](../src/modules/ai/imageTools.ts) | `generate_image` tool | +| [`vfsTools.ts`](../src/modules/ai/vfsTools.ts) | 6 VFS tools (ls/read/write/write_many/mkdir/delete) | +| [`ChatHeader.tsx`](../src/modules/ai/components/ChatHeader.tsx) | Top bar with provider badge and action buttons | +| [`ChatSidebar.tsx`](../src/modules/ai/components/ChatSidebar.tsx) | Settings panel: sessions, provider, prompt, tools, **file browser**, stats, payload, logs | +| [`ChatComposer.tsx`](../src/modules/ai/components/ChatComposer.tsx) | Input area: textarea, attachments, **file context chips**, drag-drop, image picker | +| [`MessageBubble.tsx`](../src/modules/ai/components/MessageBubble.tsx) | Message rendering: avatar, copy, markdown, streaming | +| [`PlaygroundChat.tsx`](../src/pages/PlaygroundChat.tsx) | Page wrapper: `` | +| [`ChatLogBrowser.tsx`](../src/components/ChatLogBrowser.tsx) | Log viewer + CompactTreeView (keyboard-nav JSON browser) | diff --git a/packages/ui/docs/chat.md b/packages/ui/docs/chat.md new file mode 100644 index 00000000..70cf8866 --- /dev/null +++ b/packages/ui/docs/chat.md @@ -0,0 +1,40 @@ +# Chat Export +> openai/gpt-5 โ€” 2/26/2026, 8:11:17 PM + +## System Prompt + +You are a helpful assistant. Be concise and clear. Always return Markdown raw with additional escapes ticks except for code + +--- + +### ๐Ÿง‘ User `08:10 PM` + +what injectors you have - as table, with images + +--- + +### ๐Ÿ”ง list_categories `08:10 PM` + +Calling list_categories() + +--- + +### ๐Ÿ”ง find_by_category `08:10 PM` + +Calling find_by_category(category_slug="injectors", limit=50) + +--- + +### ๐Ÿค– Assistant `08:10 PM` + +Here are the injectors we have: + +| Image | Product | Type | Highlights | Price | Link | +|---|---|---|---|---:|---| +| ![Katbot - Injection Machine](https://ytoadlpbdguriiccjnip.supabase.co/storage/v1/object/public/pictures/3bb4cfbf-318b-44d3-a9d3-35680e738421/1771352688946-9buns9.jpg) | Katbot โ€“ Injection Machine | Injector | Fast, repeatable, comfortable, safe, precise injection | 1800 | http://192.168.1.14:3333/user/3bb4cfbf-318b-44d3-a9d3-35680e738421/pages/katbot-injection-machine | +| ![Elena - Injection Machine](https://ytoadlpbdguriiccjnip.supabase.co/storage/v1/object/public/pictures/cache/4cf80b755169ce7e5faef7038d7ef79363e9ea3ecbf71f85e3d3ee50443fb4a1.jpeg) | Elena โ€“ Injection Machine | Injector | Clean, safe, predictable, precise; quick mould clamp; motorized upgrade | 5555 | http://192.168.1.14:3333/user/3bb4cfbf-318b-44d3-a9d3-35680e738421/pages/elena-injection-machine | +| ![Polymech Controller](https://ytoadlpbdguriiccjnip.supabase.co/storage/v1/object/public/pictures/3bb4cfbf-318b-44d3-a9d3-35680e738421/1771514429070-ngwu5t.jpg) | Polymech Controller | Controller (accessory) | Modern HMI; ModbusTCP/Serial/WebSocket/REST; safety features; VFD/PID support | 10 | http://192.168.1.14:3333/user/3bb4cfbf-318b-44d3-a9d3-35680e738421/pages/polymech-controller | + +Want a sideโ€‘byโ€‘side spec comparison of the two injector machines? + +--- diff --git a/packages/ui/docs/contacts.md b/packages/ui/docs/contacts.md new file mode 100644 index 00000000..c12546e7 --- /dev/null +++ b/packages/ui/docs/contacts.md @@ -0,0 +1,216 @@ +# Contacts + +User-managed address book โ€” vCard-compatible contacts, groups, import/export, and flexible `meta` jsonb. + +## Architecture Overview + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Frontend โ”‚ +โ”‚ ContactsManager.tsx โ”‚ +โ”‚ (MUI DataGrid, batch bar, dialogs) โ”‚ +โ”‚ โ”‚ +โ”‚ client-contacts.ts โ”‚ +โ”‚ (fetch wrappers, bearer token) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ /api/contacts/* + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ€“ ContactsProduct โ”‚ +โ”‚ products/contacts/index.ts โ”‚ +โ”‚ products/contacts/routes.ts โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ Supabase + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Tables โ”‚ +โ”‚ contacts โ”‚ +โ”‚ contact_groups โ”‚ +โ”‚ contact_group_members โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Database + +### `contacts` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid | PK, auto-generated | +| `owner_id` | uuid | FK โ†’ `auth.users`, not null | +| `name` | text | Full display name | +| `first_name` | text | โ€” | +| `last_name` | text | โ€” | +| `emails` | jsonb | Array of `{ email, label?, primary? }` objects | +| `phone` | text | Primary phone | +| `organization` | text | Company / org name | +| `title` | text | Job title | +| `address` | jsonb | Array of `{ street, city, state, postal_code, country, label? }` | +| `source` | text | Origin of contact (`cscart`, `import`, `manual`, โ€ฆ) | +| `language` | text | Preferred language tag (`en`, `de`, โ€ฆ) | +| `status` | text | `active` / `unsubscribed` / `bounced` / `blocked` | +| `notes` | text | Free-form notes | +| `tags` | text[] | Searchable tags | +| `log` | jsonb | Audit / event log array `[{ at, event, data }]` | +| `meta` | jsonb | Arbitrary extra fields (vCard extensions, etc.) | +| `created_at` | timestamptz | โ€” | +| `updated_at` | timestamptz | Auto-updated via trigger | + +**Indexes:** `owner_id`, `status`, `source`, `language`, `tags` (GIN), `emails` (GIN) + +### `contact_groups` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid | PK | +| `owner_id` | uuid | FK โ†’ `auth.users` | +| `name` | text | not null | +| `description` | text | โ€” | +| `meta` | jsonb | e.g. color, icon | +| `created_at` | timestamptz | โ€” | +| `updated_at` | timestamptz | โ€” | + +### `contact_group_members` + +| Column | Type | Notes | +|--------|------|-------| +| `group_id` | uuid | FK โ†’ `contact_groups` | +| `contact_id` | uuid | FK โ†’ `contacts` | +| `added_at` | timestamptz | โ€” | +| PK | composite | (`group_id`, `contact_id`) | + +### RLS + +- **Owners**: full CRUD on their own rows (`owner_id = auth.uid()`) +- **Admins** (`user_roles.role = 'admin'`): full access to all rows + +## Server API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/api/contacts` | Auth | List contacts. Query: `?group=&q=&status=&limit=&offset=` | +| `POST` | `/api/contacts` | Auth | Create contact | +| `GET` | `/api/contacts/:id` | Auth | Get single contact | +| `PATCH` | `/api/contacts/:id` | Auth | Update contact (partial) | +| `DELETE` | `/api/contacts/:id` | Auth | Delete contact | +| `POST` | `/api/contacts/import` | Auth | Bulk import JSON array or vCard text (`?format=json\|vcard`) | +| `GET` | `/api/contacts/export` | Auth | Export all contacts. Query: `?format=json\|vcard&group=` | +| `GET` | `/api/contact-groups` | Auth | List groups | +| `POST` | `/api/contact-groups` | Auth | Create group | +| `PATCH` | `/api/contact-groups/:id` | Auth | Update group | +| `DELETE` | `/api/contact-groups/:id` | Auth | Delete group | +| `GET` | `/api/contact-groups/members` | Auth | List all group memberships for the user's contacts `โ†’ { contact_id, group_id }[]` | +| `POST` | `/api/contact-groups/:id/members` | Auth | Add contacts `{ contact_ids: string[] }` `โ†’ { added: number }` | +| `DELETE` | `/api/contact-groups/:id/members/:contactId` | Auth | Remove contact from group | + +> **Route priority:** static sub-paths (`/import`, `/export`, `/members`) are registered before parameterised `:id` routes to avoid conflicts. + +## Import / Export Format + +### JSON (default) + +```json +[ + { + "email": "jane@example.com", + "name": "Jane Doe", + "first_name": "Jane", + "last_name": "Doe", + "phone": "+1 555 0100", + "organization": "Acme", + "title": "Engineer", + "address": { "city": "Berlin", "country": "DE" }, + "tags": ["customer", "newsletter"], + "meta": { "source": "cscart" } + } +] +``` + +### vCard (format=vcard) + +Standard vCard 3.0 โ€” one `BEGIN:VCARD โ€ฆ END:VCARD` block per contact. +Fields mapped: `FN`, `N`, `EMAIL`, `TEL`, `ORG`, `TITLE`, `ADR`, `NOTE`, `CATEGORIES`. +Extended fields stored in `meta` as `X-PM-*` (`X-PM-LANGUAGE`, `X-PM-SOURCE`, `X-PM-STATUS`). + +## Frontend Client + +`src/modules/contacts/client-contacts.ts` โ€” all functions inject the Supabase bearer token automatically via `authHeaders()`. Requests are routed through a shared `apiFetch` helper that resolves `VITE_SERVER_IMAGE_API_URL`. + +```ts +// Contacts CRUD +fetchContacts(options?) โ†’ Contact[] // options: { group?, q?, status?, limit?, offset? } +getContact(id) โ†’ Contact +createContact(data) โ†’ Contact +updateContact(id, data) โ†’ Contact +deleteContact(id) โ†’ void + +// Import / Export +importContacts(body, format?) โ†’ { imported: number; skipped: number } +exportContacts(options?) โ†’ string | Contact[] // options: { format?, group? } + +// Groups CRUD +fetchContactGroups() โ†’ ContactGroup[] +createContactGroup(data) โ†’ ContactGroup +updateContactGroup(id, data) โ†’ ContactGroup +deleteContactGroup(id) โ†’ void +fetchGroupMembers() โ†’ { contact_id: string; group_id: string }[] +addGroupMembers(groupId, contactIds) โ†’ { added: number } +removeGroupMember(groupId, contactId) โ†’ void +``` + +### Key Types + +```ts +interface ContactEmail { email: string; label?: string; primary?: boolean } +interface ContactAddress { street?; city?; state?; postal_code?; country?; label? } +interface Contact { id; owner_id; name?; first_name?; last_name?; emails: ContactEmail[]; + phone?; address: ContactAddress[]; source?; language?; + status?: 'active'|'unsubscribed'|'bounced'|'blocked'; + organization?; title?; notes?; tags?; log?; meta?; + created_at?; updated_at? } +interface ContactGroup { id; owner_id; name; description?; meta?; created_at?; updated_at? } +``` + +## Frontend UI โ€” `ContactsManager` + +Full-featured management interface built with **MUI DataGrid** inside a shadcn/ui shell. + +### Features + +| Feature | Detail | +|---------|--------| +| **DataGrid** | Sortable, filterable columns (name, email, status, groups, tags, actions). Checkbox selection. | +| **URL state sync** | Filter, sort, column visibility and pagination models are persisted in URL search params via `gridUtils`. | +| **Toolbar filters** | Search (`q`), group dropdown, status dropdown โ€” all reflected in URL and sent to the server. | +| **Contact dialog** | Create / edit form with email chips, tag chips, group toggles, and status select. | +| **Batch bar** | When rows are selected: set group, remove from all groups, set status, or delete. Uses `addGroupMembers`, `removeGroupMember`, `updateContact`, `deleteContact`. | +| **Import** | File picker accepts `.json` / `.vcf`, auto-detects format. | +| **Export** | Dropdown for JSON or vCard, respects active group filter. Downloads as file. | +| **Group management** | Dialog to create / delete groups. Inline in the toolbar. | + +### URL Parameters + +| Param | Source | +|-------|--------| +| `q` | Search input | +| `group` | Group filter dropdown | +| `status` | Status filter dropdown | +| `filter_*` | DataGrid column filters (via `gridUtils`) | +| `sort` | DataGrid sort model | +| `hidden` | DataGrid column visibility | +| `page` / `pageSize` | DataGrid pagination (defaults: 0 / 50) | + +## Environment Variables + +Inherits same Supabase env as the rest of the server โ€” no additional variables required. + +## Source Files + +| File | Description | +|------|-------------| +| [contacts.md](contacts.md) | This document | +| [migration](../supabase/migrations/20260306120000_create_contacts.sql) | DB schema, RLS, indexes | +| [routes.ts](../server/src/products/contacts/routes.ts) | Zod-OpenAPI route definitions | +| [index.ts](../server/src/products/contacts/index.ts) | ContactsProduct handlers | +| [client-contacts.ts](../src/modules/contacts/client-contacts.ts) | Frontend fetch wrappers | +| [ContactsManager.tsx](../src/components/ContactsManager.tsx) | Main UI component (DataGrid, batch ops, dialogs) | diff --git a/packages/ui/docs/context-aware-content.md b/packages/ui/docs/context-aware-content.md deleted file mode 100644 index cf89b3a2..00000000 --- a/packages/ui/docs/context-aware-content.md +++ /dev/null @@ -1,62 +0,0 @@ -# Creating Brand/Context Aware Articles - -The AI Page Generator allows you to create highly consistent, on-brand content by combining **visual context** (Reference Images) with **structural context** (Context Templates). This workflow ensures that generated articles not only look like your brand but also follow your specific formatting and content standards. - -## The Workflow - -### 1. Visual Context (Reference Images) - -Use **Reference Images** to establish the visual identity of your article. - -* **What to use**: Upload brand assets, logo variations, previous diagram styles, or product photos. -* **How it works**: The AI "sees" these images and uses them to: - * **Style Match**: Generate new images that match the color palette and artistic style of your references. - * **Contextual Description**: Accurately describe visual details in the text (e.g., "As shown in the diagram..."). - * **Brand Alignment**: Ensure generated visuals align with your brand's aesthetic. - -### 2. Structural Context (Context Templates) - -Use a **Context Template** in your prompt to define the exact structure and tone of the article. This serves as a "skeleton" for the AI to fill in. - -* **What is a Context Template?**: A markdown structure that defines headers, required sections, image placement, and key points, without the final text. -* **How to use**: Paste a structured template into the prompt area. - -#### Example Context Template - -```markdown -# [Article Title] - -## Overview -[Brief summary of the topic] - -## Core Concepts -* Concept A: [Description] -* Concept B: [Description] - -## Visual Breakdown -[Instruction: Generate an exploded view diagram here similar to the reference image] -* **Part 1**: Details... -* **Part 2**: Details... - -## Technical Specifications -| Spec | Value | -|------|-------| -| [Key Spec] | [Value] | - -## Conclusion -[Summarize benefits] -``` - -## Step-by-Step Guide - -1. **Open Page Generator**: Click "Create Page" -> "Generate with AI". -2. **Add Reference Images**: Click the "Add" button and select your brand assets or style references. -3. **Input Context Template**: Paste your structured markdown template into the prompt box. -4. **Refine Prompt**: Add specific instructions above or below the template (e.g., "Fill out this template for a new shelving unit product using the attached technical drawings as reference"). -5. **Generate**: The AI will combine your **Visual Context** (images) and **Structural Context** (template) to produce a production-ready article that feels authentic to your brand. - -## Best Practices - -* **Consistency**: Keep a library of standard templates for different content types (e.g., "Product Launch", "Technical Guide", "Case Study"). -* **Quality References**: High-resolution, clear reference images yield better results. -* **Explicit Instructions**: Tell the AI *how* to use the references (e.g., "Use the color scheme from Image 1 for all generated diagrams"). diff --git a/packages/ui/docs/csp-todos.md b/packages/ui/docs/csp-todos.md new file mode 100644 index 00000000..cf3ac649 --- /dev/null +++ b/packages/ui/docs/csp-todos.md @@ -0,0 +1,35 @@ +[StreamInvalidator] post:51c11e3b-f063-4889-9bf7-cc53dfb22728:create +publishHandlers.ts:253 Connecting to 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAAYwGNhQlgAABjAanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNjMnBhAAAAGJpqdW1iAAAAR2p1bWRjMm1hABEAEIAAAKoAOJtxA3VybjpjMnBhOjIzMTBhYTYyLTUwZWYtOGYwMS04ZmQyLTQzY2M2MzYzZTNhMgAAABMDanVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABLTY2JvctKEWQYrogEmGCGCWQM/MIIDOzCCAsCgAwIBAgIUAJ6vFWKBqUkCFltI/1ipbSSYHs4wCgYIKoZIzj0EAwMwUTELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkdvb2dsZSBMTEMxLTArBgNVBAMMJEdvb2dsZSBDMlBBIE1lZGlhIFNlcnZpY2VzIDFQIElDQSBHMzAeFw0yNjAyM...je5wH8nu0Yi0QwnmWIa3LOpWATXOx+jGdNe87Krvks/TO3vfAzg2YRRgpTHdlcHldFoNPRFkXalOM0Pu96rsvOdHc/xcoHzhnk1hD3hgkM/g5+7MhD18P1jJ7l0CiJ7oGBufzL2ESNZ1T/OzcxlQ0XIDxZIZ9xMsZ+lz2hxW9+eA7D6F2OKfJD2fvdZjGIuBHybCN7PD9j5kdZD1DRYRMDII3VudeLNt/vbfX3/CmGSYF5tl4bXY//uaQBjWH2/jvg7rz1VjKsDacWv7/ZOxREJbbn7qXP19Xf1iv1ivYQ2teLTSh9+K+762btI3Owc8c/826vRZf2WQSL2L0X2KhsGIlwDQrpus33b8VEAltvQLXJBsQM1P1ZNgIJUSZFzgKZI3TJmld92m0/Xl00cflsxmU4QsbyA83mA+C7ON59/fDp7X0C+N+g/TeK/+c///nPP+vv//3P79f//M///PvN//7v//77zf8BwMrEn3UCvAUAAAAASUVORK5CYII=' violates the following Content Security Policy directive: "connect-src 'self' https://*.supabase.co wss://*.supabase.co https://assets.polymech.info https://*.polymech.info https://service.polymech.info wss://service.polymech.info https://cdn.jsdelivr.net https://api.openai.com https://www.googletagmanager.com https://*.google-analytics.com https://www.google-analytics.com https://*.googleapis.com https://*.gstatic.com". The action has been blocked. +B @ publishHandlers.ts:253 +await in B +Aa @ ImageWizard.tsx:1175 +(anonymous) @ index.mjs:4 +Oe @ react-dom.production.min.js:54 +Ue @ react-dom.production.min.js:54 +(anonymous) @ react-dom.production.min.js:55 +zr @ react-dom.production.min.js:105 +Fr @ react-dom.production.min.js:106 +(anonymous) @ react-dom.production.min.js:117 +dc @ react-dom.production.min.js:273 +Pe @ react-dom.production.min.js:52 +$r @ react-dom.production.min.js:109 +Kt @ react-dom.production.min.js:74 +Gt @ react-dom.production.min.js:73Understand this error +publishHandlers.ts:253 Fetch API cannot load data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAAYwGNhQlgAABjAanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNjMnBhAAAAGJpqdW1iAAAAR2p1bWRjMm1hABEAEIAAAKoAOJtxA3VybjpjMnBhOjIzMTBhYTYyLTUwZWYtOGYwMS04ZmQyLTQzY2M2MzYzZTNhMgAAABMDanVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABLTY2JvctKEWQYrogEmGCGCWQM/MIIDOzCCAsCgAwIBAgIUAJ6vFWKBqUkCFltI/1ipbSSYHs4wCgYIKoZIzj0EAwMwUTELMAkGA1UEBhMCVVMxEzARBgNVBAoMCkdvb2dsZSBMTEMxLTArBgNVBAMMJEdvb2dsZSBDMlBBIE1lZGlhIFNlcnZpY2VzIDFQIElDQSBHMzAeFw0yNjAyM...je5wH8nu0Yi0QwnmWIa3LOpWATXOx+jGdNe87Krvks/TO3vfAzg2YRRgpTHdlcHldFoNPRFkXalOM0Pu96rsvOdHc/xcoHzhnk1hD3hgkM/g5+7MhD18P1jJ7l0CiJ7oGBufzL2ESNZ1T/OzcxlQ0XIDxZIZ9xMsZ+lz2hxW9+eA7D6F2OKfJD2fvdZjGIuBHybCN7PD9j5kdZD1DRYRMDII3VudeLNt/vbfX3/CmGSYF5tl4bXY//uaQBjWH2/jvg7rz1VjKsDacWv7/ZOxREJbbn7qXP19Xf1iv1ivYQ2teLTSh9+K+762btI3Owc8c/826vRZf2WQSL2L0X2KhsGIlwDQrpus33b8VEAltvQLXJBsQM1P1ZNgIJUSZFzgKZI3TJmld92m0/Xl00cflsxmU4QsbyA83mA+C7ON59/fDp7X0C+N+g/TeK/+c///nPP+vv//3P79f//M///PvN//7v//77zf8BwMrEn3UCvAUAAAAASUVORK5CYII=. Refused to connect because it violates the document's Content Security Policy. +B @ publishHandlers.ts:253 +await in B +Aa @ ImageWizard.tsx:1175 +(anonymous) @ index.mjs:4 +Oe @ react-dom.production.min.js:54 +Ue @ react-dom.production.min.js:54 +(anonymous) @ react-dom.production.min.js:55 +zr @ react-dom.production.min.js:105 +Fr @ react-dom.production.min.js:106 +(anonymous) @ react-dom.production.min.js:117 +dc @ react-dom.production.min.js:273 +Pe @ react-dom.production.min.js:52 +$r @ react-dom.production.min.js:109 +Kt @ react-dom.production.min.js:74 +Gt @ react-dom.production.min.js:73Understand this error +publishHandlers.ts:276 Error quick publishing image: TypeError: Failed to fetch. Refused to connect because it violates the document's Content Security Policy. + at B (publishHandlers.ts:253:30) \ No newline at end of file diff --git a/packages/ui/docs/database-todos.md b/packages/ui/docs/database-todos.md deleted file mode 100644 index 3c16a6b9..00000000 --- a/packages/ui/docs/database-todos.md +++ /dev/null @@ -1,48 +0,0 @@ - -# Database & Architecture Todos - -## Server-Side & Schema Tasks - -### Schema Changes (Postgres/Supabase) -- [ ] **Split `profiles` Table**: - - [ ] Create `user_secrets` table (Columns: `user_id` (PK, FK), `openai_api_key`, `bria_api_key`, `replicate_api_key`, `settings`, `google_api_key`). - - [ ] Migrate data from `profiles` to `user_secrets` (Ref: [`src/integrations/supabase/types.ts`](../src/integrations/supabase/types.ts)). - - [ ] Drop secret columns from `profiles`. - - [ ] Rename `profiles` to `profiles_public` (optional, or just restrict access). -- [ ] **Create `page_collaborators` Table**: - - [ ] Columns: `page_id` (FK), `user_id` (FK), `role` (enum: 'viewer', 'editor', 'owner'), `created_at`. - - [ ] Add unique constraint on `(page_id, user_id)`. -- [ ] **RLS Policies Update**: - - [ ] `user_secrets`: Enable RLS. Policy: `auth.uid() = user_id`. - - [ ] `profiles`: Policy: Public read. Update strictly limited to owner. - - [ ] `pages`: Policy: - - Read: `is_public` OR `auth.uid() = owner` OR `auth.uid() IN (select user_id from page_collaborators)`. - - Update: `auth.uid() = owner` OR `auth.uid() IN (select user_id from page_collaborators where role IN ('editor', 'owner'))`. - -### Server Logic (Node/Hono) -- [ ] **Implement `ServingProduct` Endpoints** (Ref: [`server/src/products/serving/index.ts`](../server/src/products/serving/index.ts)): - - [ ] `GET /api/feed`: Returns hydrated feed (Posts + Authors + Cover Images). - - [ ] `GET /api/profile/:id`: Returns public profile + recent posts. - - [ ] `GET /api/me/secrets`: (Secure) Returns user secrets for settings page. -- [ ] **Server-Side Injection**: - - [ ] Update `handleServeApp` in [`ServingProduct`](../server/src/products/serving/index.ts) to pre-fetch User & Feed. - - [ ] Inject into `index.html` as `window.__INITIAL_STATE__`. - ---- - -## Client-Side Tasks - -### `src/lib/db.ts` Refactor -- [ ] **Deprecate Direct Selects**: Identify all `supabase.from('posts').select(...)` calls in [`src/lib/db.ts`](../src/lib/db.ts). -- [ ] **Implement Proxy Clients**: - - [ ] Create `fetchFeedFromProxy()` calling `/api/feed` in [`src/lib/db.ts`](../src/lib/db.ts). - - [ ] Create `fetchProfileFromProxy(id)` calling `/api/profile/:id` in [`src/lib/db.ts`](../src/lib/db.ts). -- [ ] **Hydration Logic**: - - [ ] Check `window.__INITIAL_STATE__` on app boot to populate React Query cache before fetching. - -### Component Updates -- [ ] **Post Page**: - - [ ] Use `fetchPostFromProxy` (or standard `db.fetchPostById` redirected to proxy) in [`src/pages/Post.tsx`](../src/pages/Post.tsx). - - [ ] Handle 404s gracefully (See Security.md for details). -- [ ] **PageManager**: - - [ ] Update [`src/components/PageManager.tsx`](../src/components/PageManager.tsx) to fetch "My Pages" AND "Shared Pages". diff --git a/packages/ui/docs/db-caching.md b/packages/ui/docs/db-caching.md deleted file mode 100644 index 4dd5f8ce..00000000 --- a/packages/ui/docs/db-caching.md +++ /dev/null @@ -1,169 +0,0 @@ -# Short Term DB Caching Proposal - -## Objective -Reduce database load and improve response times for high-traffic, read-heavy routes by implementing a short-term caching layer using a **Generically Safe Decorator Pattern**. - -## Proposed Solution -Implement a **Generic CachedHandler Utility** (`server/src/commons/decorators.ts`) that: -1. **Auto-Generates Keys**: Defaults to URL + Query. -2. **Auth Protection**: Skips caching for Authenticated requests by default. -3. **Size Protection**: Skips caching for responses larger than a threshold (e.g. 1MB). -4. **Memory Protection**: Enforces LRU/Limits in `MemoryCache`. - -### 1. Functional Decorator -```typescript -import { Context } from 'hono'; -import { getCache } from '../commons/cache/index.js'; - -type KeyGenerator = (c: Context) => string; - -const defaultKeyInfo = (c: Context) => { - const url = new URL(c.req.url); - // Deterministic Sort: key=a&key=b vs key=b&key=a - // 1. Sort keys - url.searchParams.sort(); - return `auto-cache:${c.req.method}:${url.pathname}${url.search}`; -}; - -export const CachedHandler = ( - handler: (c: Context) => Promise, - options: { - ttl: number, - keyGenerator?: KeyGenerator, - skipAuth?: boolean, // Default true - maxSizeBytes?: number // Default: 1MB - } -) => async (c: Context) => { - // defaults - const ttl = options.ttl; - const skipAuth = options.skipAuth !== false; - const maxSizeBytes = options.maxSizeBytes || 1024 * 1024; // 1MB - const keyGen = options.keyGenerator || defaultKeyInfo; - - // 1. Auth Bypass - if (skipAuth && c.req.header('Authorization')) { - return handler(c); - } - - const cache = getCache(); - const key = keyGen(c); - const bypass = c.req.query('cache') === 'false'; - - // 2. Hit - if (!bypass) { - const cached = await cache.get(key); - if (cached) { - c.header('X-Cache', 'HIT'); - if (cached.contentType) c.header('Content-Type', cached.contentType); - return c.body(cached.data); - } - } - - // 3. Miss - const response = await handler(c); - - // 4. Save - if (response instanceof Response && response.ok) { - const cloned = response.clone(); - try { - const contentType = response.headers.get('Content-Type') || 'application/json'; - let data: any; - - // Check content length if available - const contentLength = cloned.headers.get('Content-Length'); - if (contentLength && parseInt(contentLength) > maxSizeBytes) { - // Too big, skip cache - return response; - } - - if (contentType.includes('application/json')) { - const jsonObj = await cloned.json(); - data = JSON.stringify(jsonObj); - } else { - data = await cloned.text(); - } - - // Double check actual size after reading - if (data.length > maxSizeBytes) { - // Too big, skip cache - return response; - } - - await cache.set(key, { data, contentType }, ttl); - c.header('X-Cache', bypass ? 'BYPASS' : 'MISS'); - } catch (e) { - console.error('Cache interception failed', e); - } - } - - return response; -} -``` - -### 2. Usage Implementation -In `server/src/products/serving/index.ts`: - -```typescript -// 5 minute cache, auto-key, skip if auth, max 500kb -this.routes.push({ - definition: getApiUserPageRoute, - handler: CachedHandler(handleGetApiUserPage, { ttl: 300, maxSizeBytes: 500 * 1024 }) -}); -``` - -### 3. MemoryCache Protection (Limit) -Update `server/src/commons/cache/MemoryCache.ts`: - -```typescript -// Add limit -const MAX_KEYS = 1000; - -async set(key: string, value: any, ttlSeconds: number): Promise { - this.prune(); - if (this.cache.size >= MAX_KEYS) { - const first = this.cache.keys().next().value; - this.cache.delete(first); - } - // ... set logic -} -``` - -### 4. Summary of Protections -| Protection | Mechanism | Benefit | -| :--- | :--- | :--- | -| **Data Leak** | `skipAuth: true` | Prevents private data being cached/served to public. | -| **Stale Data** | `ttl` | Ensures updates propagate eventually. | -| **OOM (Large Item)** | `maxSizeBytes` | Prevents caching huge responses (e.g. giant JSONs). | -| **OOM (Many Items)** | `MAX_KEYS` | Prevents unlimited growth of the cache map. | -| **Performance** | `X-Cache` | Visibility into hit rates. | - -### 5. Sequence Diagram (Final) -```mermaid -sequenceDiagram - participant Client - participant Dec as CachedHandler - participant Cache as MemoryCache - participant H as Handler - - Client->>Dec: GET /api/data - Dec->>Dec: Check Auth Header? - opt Authenticated - Dec->>H: Invoke Handler Directly - H-->>Client: Returns Private Data - end - - Dec->>Cache: get(key) - alt Hit - Cache-->>Client: Returns Data (HIT) - else Miss - Dec->>H: Invoke Handler - H-->>Dec: Returns Response - Dec->>Dec: Check Size < 1MB? - alt Small Enough - Dec->>Cache: set(key, data) - Dec-->>Client: Returns (MISS) - else Too Big - Dec-->>Client: Returns (MISS - No Cache) - end - end -``` diff --git a/packages/ui/docs/db.md b/packages/ui/docs/db.md deleted file mode 100644 index f842f11e..00000000 --- a/packages/ui/docs/db.md +++ /dev/null @@ -1,9 +0,0 @@ - -# [DEPRECATED] Database Consolidation Plan - -> **Note**: This document has been split into more specific task lists. Please refer to: -> - [Database Todos & Schema](./database-todos.md) -> - [Security & Auth Plans](./security.md) -> - [Caching Strategy](./caching.md) - -This file remains for historical context but may be out of date. diff --git a/packages/ui/docs/dnd.md b/packages/ui/docs/dnd.md new file mode 100644 index 00000000..4d089ba2 --- /dev/null +++ b/packages/ui/docs/dnd.md @@ -0,0 +1,258 @@ +# Drag-and-Drop Integration with dnd-kit + +Proposal for adding drag-and-drop widget management to the page editor using `@dnd-kit/core` (6.3.1), `@dnd-kit/sortable` (10.0.0), and `@dnd-kit/utilities` (3.2.2) โ€” all already installed. + +--- + +## Current State + +| Concern | Implementation | File | +|---------|---------------|------| +| Widget insertion | Click-to-add via `onToggleWidget` | `PageRibbonBar.tsx` | +| Widget reorder | `MoveWidgetCommand` (arrow D-pad) | `commands.ts`, `LayoutManager.ts` | +| Native file/URL drop | Window-level `drop` listener | `GlobalDragDrop.tsx` | +| Native drag state | `DragDropContext` (custom React context) | `contexts/DragDropContext.tsx` | + +**Key detail:** The custom `DragDropContext` already has a `polymech/internal` discriminator โ€” it skips drags with `types.includes('polymech/internal')`, so dnd-kit drags (which use `setData` with custom types) will **not** trigger the global file-drop overlay. No conflict. + +--- + +## Proposed Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DndContext (from @dnd-kit/core) โ”‚ +โ”‚ โ””โ”€ mounted in UserPageEdit.tsx (wraps canvas + ribbon) โ”‚ +โ”‚ โ”œโ”€ DragOverlay: ghost preview of dragged widget โ”‚ +โ”‚ โ”œโ”€ Draggable sources: โ”‚ +โ”‚ โ”‚ โ”œโ”€ RibbonItemSmall (drag from ribbon) โ”‚ +โ”‚ โ”‚ โ””โ”€ WidgetItem / FlexWidgetItem (drag to reorder)โ”‚ +โ”‚ โ””โ”€ Droppable targets: โ”‚ +โ”‚ โ”œโ”€ Container cells (LayoutContainer grid slots) โ”‚ +โ”‚ โ””โ”€ Flex cells (row:column in FlexContainer) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Data Model + +All draggables attach structured data via `useDraggable({ data })`: + +```ts +// Dragging a new widget from the ribbon +{ type: 'new-widget', widgetId: 'photo-card' } + +// Dragging an existing widget within/across containers +{ type: 'existing-widget', widgetInstanceId: 'widget-123', sourceContainerId: 'container-456' } +``` + +All droppables identify themselves via `useDroppable({ data })`: + +```ts +// Regular container cell +{ type: 'container-cell', containerId: 'container-456', index: 3 } + +// Flex container cell +{ type: 'flex-cell', containerId: 'container-789', rowId: 'row-abc', column: 1 } +``` + +--- + +## Implementation Plan + +### Phase 1: DndContext Setup + +#### `UserPageEdit.tsx` โ€” Wrap editor in `DndContext` + +```tsx +import { DndContext, DragOverlay, closestCenter, pointerWithin } from '@dnd-kit/core'; + +// Inside the component: +const [activeDrag, setActiveDrag] = useState(null); + + setActiveDrag(active.data.current)} + onDragEnd={handleDragEnd} + onDragCancel={() => setActiveDrag(null)} +> + + + + {activeDrag && } + + +``` + +`handleDragEnd` logic: + +```ts +function handleDragEnd({ active, over }) { + setActiveDrag(null); + if (!over) return; + + const src = active.data.current; + const dst = over.data.current; + + if (src.type === 'new-widget') { + // Insert new widget at drop target + if (dst.type === 'container-cell') { + addWidgetToPage(pageId, dst.containerId, src.widgetId, dst.index); + } else if (dst.type === 'flex-cell') { + addWidgetToPage(pageId, dst.containerId, src.widgetId, dst.column, dst.rowId); + } + } else if (src.type === 'existing-widget') { + // Move widget via command (for undo/redo) + executeCommand(new MoveWidgetToTargetCommand(pageId, src.widgetInstanceId, dst)); + } +} +``` + +--- + +### Phase 2: Draggable Ribbon Items + +#### `PageRibbonBar.tsx` โ€” Make widget buttons draggable + +Wrap `RibbonItemSmall` with `useDraggable`. Click still works (dnd-kit only initiates drag after movement threshold via `activationConstraint`). + +```tsx +const DraggableRibbonItem = ({ widgetId, ...props }) => { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `ribbon-${widgetId}`, + data: { type: 'new-widget', widgetId }, + }); + + return ( +
+ +
+ ); +}; +``` + +> [!TIP] +> Use `activationConstraint: { distance: 8 }` on the `PointerSensor` to prevent accidental drags from clicks. + +--- + +### Phase 3: Droppable Container Cells + +#### `LayoutContainer.tsx` โ€” Grid cells as drop targets + +Each column slot becomes a `useDroppable` target: + +```tsx +const DroppableCell = ({ containerId, index, children }) => { + const { setNodeRef, isOver } = useDroppable({ + id: `cell-${containerId}-${index}`, + data: { type: 'container-cell', containerId, index }, + }); + + return ( +
+ {children} +
+ ); +}; +``` + +#### `FlexibleContainerRenderer.tsx` โ€” Flex cells as drop targets + +Each `(rowId, column)` pair becomes a drop target: + +```tsx +const DroppableFlexCell = ({ containerId, rowId, column, children }) => { + const { setNodeRef, isOver } = useDroppable({ + id: `flex-${containerId}-${rowId}-${column}`, + data: { type: 'flex-cell', containerId, rowId, column }, + }); + + return ( +
+ {children} +
+ ); +}; +``` + +--- + +### Phase 4: Draggable Existing Widgets (Reordering) + +#### `LayoutContainer.tsx` and `FlexibleContainerRenderer.tsx` + +Use `useSortable` from `@dnd-kit/sortable` for intra-container reordering, with `useDraggable` for cross-container moves: + +```tsx +const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: widget.id, + data: { + type: 'existing-widget', + widgetInstanceId: widget.id, + sourceContainerId: container.id, + }, +}); +``` + +--- + +### Phase 5: New Command for Targeted Moves + +#### `commands.ts` โ€” `MoveWidgetToTargetCommand` + +A new command that moves a widget to a specific target location (container + position), supporting undo by capturing the original location: + +```ts +export class MoveWidgetToTargetCommand implements Command { + // Captures source (containerId, index/rowId/column) and target + // execute(): removes from source, inserts at target + // undo(): removes from target, re-inserts at source +} +``` + +--- + +## Coexistence with GlobalDragDrop + +The custom `DragDropContext` checks `e.dataTransfer?.types.includes('polymech/internal')` and **skips** internal drags. dnd-kit uses pointer events, not native HTML5 drag events, so: + +1. **No event collision** โ€” dnd-kit pointer captures don't fire `dragenter`/`dragleave` on `window` +2. **Overlay suppression** โ€” `GlobalDragDrop` only renders when `isDragging && !isLocalZoneActive`. During dnd-kit drags, `isDragging` stays `false` in the custom context +3. **Native file drops still work** โ€” External file/URL drops bypass dnd-kit (which only tracks its registered draggables) + +> [!IMPORTANT] +> No changes needed to `GlobalDragDrop.tsx` or `DragDropContext.tsx`. The two systems operate on completely separate event channels. + +--- + +## Files to Modify + +| File | Change | +|------|--------| +| `UserPageEdit.tsx` | Wrap in `DndContext`, add `DragOverlay`, `handleDragEnd` | +| `PageRibbonBar.tsx` | Wrap widget buttons with `useDraggable` | +| `LayoutContainer.tsx` | Add `useDroppable` to grid cells, `useSortable` to widgets | +| `FlexibleContainerRenderer.tsx` | Add `useDroppable` to flex cells, `useSortable` to widgets | +| `commands.ts` | Add `MoveWidgetToTargetCommand` | +| `LayoutManager.ts` | Add `moveWidgetToTarget()` static method | + +### New Files + +| File | Purpose | +|------|---------| +| `DragPreview.tsx` | `DragOverlay` content โ€” shows widget name + icon while dragging | + +--- + +## Phasing + +| Phase | Scope | Effort | +|-------|-------|--------| +| 1 | DndContext setup + handler skeleton | Small | +| 2 | Draggable ribbon items (drag-to-add) | Small | +| 3 | Droppable container/flex cells | Medium | +| 4 | Draggable existing widgets (reorder + cross-container) | Medium | +| 5 | New command for cross-container targeted moves | Small | + +Phases 1โ€“3 deliver the most impactful feature (drag from ribbon to canvas). Phase 4โ€“5 adds reordering, which is lower priority since the D-pad already covers this. diff --git a/packages/ui/docs/emails.md b/packages/ui/docs/emails.md new file mode 100644 index 00000000..82fcff5c --- /dev/null +++ b/packages/ui/docs/emails.md @@ -0,0 +1,210 @@ +# Email System + +Newsletter and marketing email pipeline โ€” from page rendering to delivery and tracking. + +## Architecture Overview + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” fetch HTML โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ CLI โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€>โ”‚ Server โ”‚ +โ”‚ email-send โ”‚ /email-preview โ”‚ (pages-email.ts) โ”‚ +โ”‚ โ”‚<โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ โ”‚ +โ”‚ sends via โ”‚ rendered HTML โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”‚ @polymech/ โ”‚ +โ”‚ mail โ”‚โ”€โ”€ POST /track โ”€โ”€โ”€> โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚<โ”€โ”€ {id, token} โ”€โ”€ โ”‚ Server API โ”‚ +โ”‚ โ”‚โ”€โ”€ PATCH /track/:id โ”‚ (EmailProduct) โ”‚ +โ”‚ โ”‚ โ”‚ โ†’ marketing_emailsโ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Flow:** +1. CLI authenticates with Supabase (get bearer token) +2. Optionally filters unsubscribed (`--check-unsubs`) and already-sent (`--check-sent`) recipients +3. Fetches rendered email HTML from server (`/user/:id/pages/:slug/email-preview`) +4. Applies `--var-*` template variables (`keep=true` to preserve `${unsubscribe}`) +5. Injects tracking parameters into all links/images (via cheerio) +6. For each recipient: + - `POST /api/email/track` โ†’ creates pending row, returns `{id, unsubscribe_token}` + - Substitutes `${unsubscribe}` with per-recipient unsubscribe URL + - Sends via `@polymech/mail` (nodemailer, `newsletter` transport) + - `PATCH /api/email/track/:id` โ†’ updates status to `sent`/`failed` + +## CLI Usage + +```bash +pm-cli-cms email-send --page-slug [options] +``` + +### Required + +| Arg | Description | +|-----|-------------| +| `--page-slug` | Page slug to render as email | + +### Optional + +| Arg | Default | Description | +|-----|---------|-------------| +| `--user` | `cgo` | User identifier (username or UUID) | +| `--subject` | `Newsletter Polymech - DD:HH:mm` | Email subject line | +| `--recipient` | `cgoflyn@gmail.com` | Single email or path to `.json` contacts file | +| `--targethost` | `https://service.polymech.info` | Server base URL | +| `--lang` | โ€” | Language tag for translated content | +| `--dry` | `false` | Log actions without sending | +| `--tracking` | `mail-DD-HH-mm` | Tracking param appended to all href/src URLs | +| `--campaign` | โ€” | Campaign identifier string | +| `--check-unsubs` | `false` | Query DB and skip unsubscribed recipients | +| `--check-sent` | `false` | Skip recipients already sent to (matches email + campaign + subject) | +| `--var-` | โ€” | Template variable: `--var-name=hobbit` โ†’ `${name}` becomes `hobbit` | + +### Examples + +```bash +# Basic send +pm-cli-cms email-send --page-slug newsletter-march + +# Send to contacts file with campaign tracking +pm-cli-cms email-send --page-slug newsletter-march \ + --recipient ./ref/cscart-contacts.json \ + --campaign spring-2026 \ + --var-emailName "Dear Customer" + +# Dry run with language override +pm-cli-cms email-send --page-slug newsletter-march \ + --dry --lang de + +# Full safety checks (skip unsubs + already sent) +pm-cli-cms email-send --page-slug newsletter-march \ + --recipient ./ref/cscart-contacts.json \ + --campaign spring-2026 \ + --check-unsubs --check-sent + +# Send to a single recipient +pm-cli-cms email-send --page-slug email-2026 --targethost http://localhost:3333/ --var-emailName "Hobbit Ex" --subject test2 --campaign=test2222 --lang=de + +``` + +## Contacts File Format + +JSON array with `email` and optional `name` fields (e.g. CS-Cart export): + +```json +[ + { "email": "user@example.com", "name": "John", "Language": "en" }, + { "email": "other@example.com", "name": "Jane", "Language": "de" } +] +``` + +Object format (legacy): +```json +{ + "contact1": { "email": "user@example.com" }, + "contact2": { "email": "other@example.com" } +} +``` + +Only entries with a non-empty `email` field are used. See [`cli-ts/ref/cscart-contacts.json`](../cli-ts/ref/cscart-contacts.json) for a real example. + +## Template Variables + +The server renders email HTML with `substitute()` from `@polymech/commons/variables`. Unresolved `${key}` patterns are preserved (`keep=true`) so the CLI can resolve them client-side. + +Pass variables via `--var-=`: + +```bash +--var-emailName "John Doe" --var-company "Acme Inc" +``` + +This replaces `${emailName}` and `${company}` in the rendered HTML. + +**Reserved variable:** `${unsubscribe}` โ€” auto-substituted per recipient with the unsubscribe URL. Do not pass this via `--var-*`. + +## Tracking + +Every `` and `` in the email gets `?tracking=` appended (via cheerio DOM manipulation). Defaults to `mail-DD-HH-mm` timestamp. + +The tracking ID is also stored in `marketing_emails.tracking_id`. + +## Server API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/api/email/unsubscribe/:token` | None | Marks recipient as unsubscribed | +| `POST` | `/api/email/track` | Admin | Creates pending tracking row, returns `{id, unsubscribe_token}` | +| `PATCH` | `/api/email/track/:id` | Admin | Updates row status (`sent`/`failed`/`bounced`) | +| `GET` | `/api/render/email/:id` | Auth | Renders email HTML for a post | +| `POST` | `/api/send/email/:id` | Auth | Generates and sends email for a post | + +## Database: `marketing_emails` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid | PK, auto-generated | +| `name` | text | Contact name | +| `email` | text | **not null** | +| `status` | text | `pending` / `sent` / `failed` / `bounced` | +| `sent_at` | timestamptz | When successfully sent | +| `page_slug` | text | **not null** | +| `subject` | text | Email subject | +| `tracking_id` | text | Tracking tag | +| `campaign` | text | Campaign identifier | +| `lang` | text | Language used | +| `error_message` | text | Failure reason | +| `retry_count` | int | Send attempts (default 0) | +| `last_retry_at` | timestamptz | Last retry timestamp | +| `sender_id` | uuid | FK โ†’ `auth.users` | +| `from_address` | text | Sender email | +| `unsubscribed` | boolean | Default `false` | +| `unsubscribed_at` | timestamptz | When unsubscribed | +| `unsubscribe_token` | uuid | Auto-generated, used in unsubscribe links | +| `meta` | jsonb | Flexible metadata (vars, targethost) | + +**Indexes:** `email`, `status`, `page_slug`, `tracking_id`, `campaign`, `unsubscribe_token` + +**Migration:** [`supabase/migrations/20260302163400_create_marketing_emails.sql`](../supabase/migrations/20260302163400_create_marketing_emails.sql) + +## Unsubscribe + +### Flow + +1. CLI inserts a `pending` row via `POST /api/email/track` โ†’ gets `unsubscribe_token` +2. CLI substitutes `${unsubscribe}` in HTML with `targethost/api/email/unsubscribe/` +3. Email is sent with the per-recipient unsubscribe URL +4. Recipient clicks โ†’ `GET /api/email/unsubscribe/:token` โ†’ sets `unsubscribed=true`, shows confirmation + +### Template + +The unsubscribe link lives in [`public/widgets/email-clean/social_links.html`](../public/widgets/email-clean/social_links.html): + +```html +Unsubscribe +``` + +### RLS + +- **Admins**: full access to all rows +- **Authenticated users**: can view rows matching their email +- **Anonymous**: can update `unsubscribed=true` only (via token) + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SUPABASE_URL` | โœ“ | Supabase project URL | +| `SUPABASE_ANON_KEY` | โœ“ | Supabase anon/publishable key | +| `ADMIN_EMAIL` | โœ“ | Admin email for auth | +| `ADMIN_PASSWORD` | โœ“ | Admin password for auth | +| `TEST_EMAIL_FROM` | โ€” | Sender address (default: `newsletter@osr-plastic.org`) | + +## Source Files + +| File | Description | +|------|-------------| +| [email-send.ts](../cli-ts/src/lib/email-send.ts) | Core library: auth, fetch, substitute, track, send | +| [send.ts](../cli-ts/src/commands/email/send.ts) | CLI wrapper (yargs args, var extraction) | +| [index.ts](../server/src/products/email/index.ts) | Server EmailProduct (render, send, track, unsubscribe) | +| [routes.ts](../server/src/products/email/routes.ts) | Route definitions (zod-openapi) | +| [pages-email.ts](../server/src/products/serving/pages/pages-email.ts) | Page โ†’ email HTML renderer | +| [social_links.html](../public/widgets/email-clean/social_links.html) | Email footer template (social icons + unsubscribe) | +| [nodemailer/index.ts](../../polymech-mono/packages/mail/src/lib/nodemailer/index.ts) | `@polymech/mail` transport wrapper | diff --git a/packages/ui/docs/feature-filebrowser.md b/packages/ui/docs/feature-filebrowser.md new file mode 100644 index 00000000..c8c9b2e4 --- /dev/null +++ b/packages/ui/docs/feature-filebrowser.md @@ -0,0 +1,120 @@ +# Feature: FileBrowser (VFS) + +## Product Description (Copy/Paste Ready) +> **Polymech Virtual File System (VFS)** +> Experience native-grade file management directly within your browser. The Polymech VFS FileBrowser is a blazing-fast, dual-pane explorer designed for modern CMS workflows. Drag, drop, and navigate through your assets with intuitive keyboard shortcuts and zero-latency loading. Unlike basic media libraries, it features a built-in rendering engine capable of instantly previewing everything from standard images and PDFs to raw markdown, spreadsheets, and complex engineering 3D models (STL/STEP/OBJ)โ€”all without requiring intermediate downloads. + +## Comparison vs. Other Web Filebrowsers + +* **Traditional CMS Media Libraries (WordPress, Drupal):** Standard platforms heavily favor flat grids or tagged media blobs, restricting hierarchical folder organizations. The Polymech VFS acts like a true operating system file manager, enforcing strict tree hierarchies and directory paths, while still allowing grid-based thumbnail views. +* **Headless Asset Managers (Sanity, Strapi):** Often architected purely as metadata stores linking out to CDN URLs. The Polymech VFS operates on physical nested directories giving developers full UNIX-like path predictability. +* **Out-of-the-Box Web Explorers (elFinder, FilePond):** Most third-party integrations require aging backend dependencies (PHP) or use heavy, unoptimized DOM techniques. The Polymech FileBrowser is built natively in modern React. It utilizes zero-repaint layout effects to lock keyboard focus seamlessly and ensures instant UI reaction speeds. +* **Specialized Previews (3D & Code):** Almost no native web filebrowsers offer immediate in-browser rendering for 3D engineering formats (`.stl`, `.stp`, `.obj`) or syntax-highlighted code files (`.tsx`, `.py`, etc.) without downloading them first. The Polymech VFS features a dynamic `FileViewerRegistry` that handles these specialized mesh and plaintext previews instantly. + +--- + +The FileBrowser is a fully-featured, dual-pane virtual file system (VFS) client built into the Polymech platform. It provides native-like file exploration capabilities directly in the browser, featuring robust keyboard navigation, drag-and-resize panels, type-safe file discovery, and extensive previewing capabilities for a wide variety of file formats, including 3D models. + +## Core Architecture + +The FileBrowser operates on a Virtual File System structure, connecting to a backend VFS API that mounts specific folders or resources. It extensively leverages standard modern React patterns (Hooks, Contexts, Layout Effects) to build a zero-latency feel for the user. + +### Main Component +- **[`./src/apps/filebrowser/FileBrowserPanel.tsx`](../src/apps/filebrowser/FileBrowserPanel.tsx)** + The entry point component. It orchestrates layout via `ResizablePanelGroup` and houses the toolbar, dialog popups, and the dual-pane system (the explorer on the left, the preview pane on the right). Core logic is delegated to modular hooks listed below. + +### State Hooks +The business logic of the browser is split into modular hooks located in `./src/modules/storage/hooks/`: + +#### Foundation Hooks (Data & Primitives) +- **[`useVfsAdapter.ts`](../src/modules/storage/hooks/useVfsAdapter.ts)** + Handles data fetching from the backend API. Abstracts away directory fetching, cache management, fallback README indexing, and recursive size queries (`includeSize`). +- **[`useSelection.ts`](../src/modules/storage/hooks/useSelection.ts)** + Governs state for selected files, handles batch selection arrays, and calculates item counts. +- **[`useKeyboardNavigation.ts`](../src/modules/storage/hooks/useKeyboardNavigation.ts)** + Intercepts standard keyboard inputs within the module to provide accessible navigation. Converts array/grid positional awareness to focus updates natively. +- **[`useFilePreview.ts`](../src/modules/storage/hooks/useFilePreview.ts)** + Manages states and refs for opening files in overlays/lightboxes. + +#### Orchestration Hooks (Compose the primitives) +- **[`useDefaultSelectionHandler.ts`](../src/modules/storage/hooks/useDefaultSelectionHandler.ts)** + Auto-select engine that runs on directory load: selects `readme.md` when present, restores the parent directory item on "go up" (return-target pattern), and auto-opens `initialFile` from URL params. +- **[`useDefaultKeyboardHandler.ts`](../src/modules/storage/hooks/useDefaultKeyboardHandler.ts)** + Global keyboard shortcut listener (`Ctrl+F` / `F3` for search), typeahead search buffer management (with auto-clear on directory change), focus management on view mode switches, and wires the lower-level `useKeyboardNavigation`. +- **[`useDefaultActions.ts`](../src/modules/storage/hooks/useDefaultActions.ts)** + Centralizes all action callbacks: download (single/multi/dir-zip), lightbox navigation (prev/next), close-and-restore-focus patterns, double-click handling, and a unified relative link resolver (`resolveRelativeVfsLink`) that de-duplicates the previously separate readme-pane and file-viewer-pane link handlers. + +--- + +## File Views + +The left pane of the file browser provides three rendering modes for the fetched directory contents. + +- **[`./src/apps/filebrowser/FileTree.tsx`](../src/apps/filebrowser/FileTree.tsx)** + The Tree view mode provides an expanding folder hierarchy approach. + *Note: Recursion and layout-level rendering effects explicitly override grid-snapping hooks to avoid redundant focus issues.* +- **[`./src/modules/storage/FileListView.tsx`](../src/modules/storage/FileListView.tsx)** + A detailed, vertical list rendering standard OS-like columns (Icon, Name, Size, Date). +- **[`./src/modules/storage/FileGridView.tsx`](../src/modules/storage/FileGridView.tsx)** + A resizable thumbnail rendering mode tailored for media directories. +- **[`./src/modules/storage/ThumbPreview.tsx`](../src/modules/storage/ThumbPreview.tsx)** + Handles rendering fast thumbnail previews inline for media and models, mapping correctly scaled image requests to the backend. + +--- + +## Preview Architecture (FileViewerRegistry) + +One of the stand-out features of the FileBrowser is the ability to preview files directly within the UI or via an isolated popup overlay, without downloading them individually. +This routing is handled by: +- **[`./src/modules/storage/FileViewerRegistry.tsx`](../src/modules/storage/FileViewerRegistry.tsx)** + +Depending on the `mime` category or file extension, the requested node is dynamically routed to the appropriate specialized viewer component under `./src/modules/storage/views/`: + +1. **`ImageLightbox`** ([`./src/components/ImageLightbox.tsx`](../src/components/ImageLightbox.tsx)) + Native integration for standard images via `ResponsiveImage`. +2. **`PdfLightbox.tsx`** + Built over `react-pdf`, handles rendering standard `.pdf` outputs. +3. **`SpreadsheetLightbox.tsx`** + Preview tables like `.csv` and `.xlsx`. +4. **`LightboxIframe.tsx`** + Renders pure `.html`/`.htm` safely inside an iframe. +5. **`LightboxText.tsx`** + Code and text preview utilizing `Prism.js` for on-the-fly markdown and syntax highlighting. +6. **`ThreeDViewer.tsx`** + A robust 3D model engine implementation previewing `.stl`, `.obj`, `.step` and other engineering-centric mesh types up to specific memory thresholds. + +If a folder is selected instead of a file, the right-pane preview defaults to: +- A rendered `README.md` if one sits natively inside the directory. +- An internal mounted `FileBrowserPanel` in thumbnail mode strictly locked as a directory sub-preview (`autoFocus=false`). + +--- + +## Keyboard Navigation & Focus Flow + +The keyboard and tab-index systems were optimized extensively to act natively: +- Pressing `Arrow Keys` calculates grid-level math to jump up/down/left/right seamlessly. +- Pressing `Enter` commits to entering directories. +- Pressing `Backspace` triggers a "Return-to-Sender" effect (jumping out of folders automatically shifts focus onto the parent directory node precisely). +- `Ctrl + F` / `F3` triggers native file search scoped to the currently tracked tree context. +- Typeahead search buffer auto-clears on directory navigation preventing stale matches from locking navigation. +- Unmounting/mounting directories handles DOM manipulation strictly in `useLayoutEffect`, enforcing zero-repaint focus locking on tree and list container nodes. + +--- + +## Backend VFS Service + +The backend drives the file structure by translating standardized REST commands to the file-system operations safely. +- **[`./server/src/products/storage/api/vfs-routes.ts`](../server/src/products/storage/api/vfs-routes.ts)** + Exposes the OpenAPI specifications and strict route mapping inputs for the File System. +- **[`./server/src/products/storage/api/vfs.ts`](../server/src/products/storage/api/vfs.ts)** + Contains the `MountManager`, global route validations, rate-limited file-tree recursive walks (Search API), and ACL-governed path sandboxing logic. + +--- + +## Future Improvements (TODOs) + +- **I18n (Internationalization):** Abstract hardcoded strings into the globalization architecture via **[`../src/i18n.tsx`](../src/i18n.tsx)** to support multilingual FileBrowser deployments. +- **Keyboard Navigation:** Expand keyboard shortcuts for batch selection manipulation and add comprehensive focus trapping within isolated preview lightboxes. +- **Security (User Content):** Implement stricter Content Security Policies (CSP) within `LightboxIframe` and sanitization checks for user-uploaded HTML/SVG content to prevent stored XSS attacks. +- **Context API Abstraction:** Formalize a generic `ViewerControlContext` to clean up the prop-drilling required for passing preview state (like `LightboxNode`) down deeper into specialized viewers. + diff --git a/packages/ui/docs/feed.md b/packages/ui/docs/feed.md deleted file mode 100644 index b0772d8c..00000000 --- a/packages/ui/docs/feed.md +++ /dev/null @@ -1,79 +0,0 @@ -# Instagram-like Feed Implementation Plan - -## Objective -Create a responsive, immersive feed experience that adapts to device size: -- **Desktop/Large Screens**: Retain the current `PhotoGrid` (grid view). -- **Mobile**: Implement a new `Feed` view (vertical list) similar to Instagram. -- **Carousel**: Support horizontal swiping (left/right) through multiple pictures within a single post. -- **Performance**: Implement "load ahead" strategy (buffer ~5 posts) to ensure smooth scrolling without loading the entire database. - -## Architecture & Components - -### 1. Data Layer Enhancements -Current `PhotoGrid` logic fetches posts and selects a single "cover" image. -We need to modify the data transformation to pass *all* visible pictures to the UI components. - -- **Query**: Keep fetching `posts` with `pictures`. -- **Transformation**: Instead of flattening to a single `MediaItem`, we need a structure that preserves the list of pictures for each post. - ```typescript - interface FeedPost { - id: string; // Post ID - user_id: string; // Author - pictures: MediaItemType[]; // Array of pictures in the post - // ... other post metadata (title, description, etc.) - } - ``` - -### 2. New `Feed` Component (Mobile) -A new component `src/components/Feed.tsx` will be created for the mobile view. -- **Layout**: Vertical list of full-width cards. -- **Virtualization**: Use `react-window` or simpler intersection observer-based rendering to only render posts in (and slightly outside) the viewport. -- **Preloading**: Ensure the next 5 image/video assets are preloaded. - -### 3. Updated `MediaCard` / New `FeedCard` -`MediaCard` currently handles a single media item. We have two options: -1. **Refactor `MediaCard`**: Add support for an array of media and internal carousel logic. -2. **Create `FeedCard`**: A specialized card for the Feed view that wraps `MediaCard` or implements its own carousel. - * *Decision*: Use `FeedCard` (or `PostCard`) to encapsulate the carousel logic (Embla Carousel or similar) and use `MediaCard` for individual slides if needed, or implement a lighter slide view. - * **Carousel**: Must support touch gestures for left/right swiping. - -### 4. `PhotoGrid` Updates -- **Logic Separation**: Extract the data fetching hook (e.g., `useFeedMedia`) so both `PhotoGrid` and `Feed` can share the same data source and state (likes, etc.). -- **Responsive Switch**: In `Index.tsx`, conditionally render `PhotoGrid` (desktop) or `Feed` (mobile). Or render both and hide via CSS (better for SSR/hydration matching, but heavier on DOM). Better to use a valid hook for `isMobile`. - -## Implementation Steps - -### Phase 1: Data & Hooks -1. Create `useFeedQuery` hook to fetch posts + pictures. -2. Implement pagination (infinite scroll) logic (load 10, load next 10 when bottom reached). -3. Preloading utility: Function to preload images `n` indexes ahead of the current viewport item. - -### Phase 2: Carousel Component -1. Implement a Swipe/Carousel component (using `embla-carousel-react` or purely custom CSS scroll-snap). -2. Ensure it handles image aspect ratios gracefully (Instagram usually restricts to 4:5 or square, but we might support flexible). - -### Phase 3: `MobileFeed` Component -1. Create the vertical list layout. -2. Implement the "Load 5 ahead" logic (prefetching images for the next 5 cards). -3. Integrate the Carousel for multi-image posts. - -### Phase 4: Integration -1. Update `Index.tsx` to switch between `PhotoGrid` and `MobileFeed`. -2. Ensure shared state (Likes, Comments) works in both views. - -## Technical Details - -### "Load 5 Ahead" Strategy -- **Intersection Observer**: Watch the last rendered element to trigger fetching the next page. -- **Image Preloading**: Watch the *currently visible* post index. Automatically create `Link rel="preload"` or `new Image()` for the `cover` images of the next 5 posts. -- **Carousel Preloading**: If a user stops on a post, prioritize loading the *next* slide of that specific post. - -### Swiping Interaction -- **Carousel (Inner)**: Swiping horizontally moves between pictures of the *same* post. -- **Feed (Outer)**: Scrolling vertically moves between *different* posts. - -## Proposed File Structure -- `src/components/feed/Feed.tsx` -- `src/components/feed/FeedCard.tsx` (Handles the carousel) -- `src/components/feed/FeedCarousel.tsx` (The actual swiper) -- `src/hooks/useFeed.ts` (Data fetching logic) diff --git a/packages/ui/docs/gdrp.md b/packages/ui/docs/gdrp.md new file mode 100644 index 00000000..313c525a --- /dev/null +++ b/packages/ui/docs/gdrp.md @@ -0,0 +1,58 @@ +# GDPR (General Data Protection Regulation) & ePrivacy Compliance Report + +## Overview +This report evaluates the current tracking, analytics, and marketing campaign features of the platform against the requirements of the GDPR and ePrivacy Directive. + +With the recent additions of detailed analytics, session tracking, and individual email tracking, the platform collects, processes, and stores Personal Identifiable Information (PII). + +## 1. Analytics & Web Tracking (Cookies & PII) +**Current State:** +- The `analyticsMiddleware` generates and assigns a `SESSION_COOKIE` (`pm_sid`) to track website visitors across sessions. +- IP addresses are **anonymized** before writing to `analytics.jsonl` โ€” the last octet is zeroed for IPv4 (e.g. `203.0.113.47` โ†’ `203.0.113.0`) and the last 5 groups are zeroed for IPv6. Full IPs are still used transiently for geo-resolution (BigDataCloud API) and cached in `cache/geoip.json` for operational purposes. +- `userEmail` and `userName` are **not logged**. Only `userId` is persisted for correlation. +- User Agents, Referers, Language, and inferred Geographic locations are logged. + +**Remaining Deficiency:** +- **Cookie Consent:** The ePrivacy Directive requires explicit, opt-in consent *before* setting any non-essential cookies (including analytics and session tracking IDs). Currently, the `pm_sid` cookie is placed automatically on all routes not in the ignore list. + +**Recommendation:** +- **Consent Banner:** Implement a Cookie Consent UI. Only set the `SESSION_COOKIE` and execute the tracking portion of the middleware if the user has explicitly accepted. + +### Google Analytics (GA4) +**Status: โœ… Removed** +- The GA4 script (`G-QZ02JX0J0L`) has been **removed** from `index.html`. No third-party analytics cookies are set, no data is transmitted to Google. + +### Security Middleware (unaffected by anonymization) +The `rateLimiter` and `autoBan` middleware continue to use **full, un-anonymized IPs** in-memory at request time. These IPs are never written to the JSONL analytics log โ€” they exist only in transient memory structures (`Map`) and the operational `config/ban.json` blocklist. This is permissible under GDPR's "legitimate interest" basis (Article 6(1)(f)) for security and abuse prevention. + +## 2. Marketing Campaigns & Email Tracking +**Status: โœ… Per-contact tracking disabled** +- The 1x1 tracking pixel (`pixel.gif`) injection is **commented out** โ€” no individual open tracking. +- Per-contact `c_id` URL parameters are **commented out** โ€” no individual click tracking. +- The `pixel.gif` route is **disabled** in the `CampaignsProduct` route registration. +- Campaign-level `tracking` param is preserved on links (non-PII, used for aggregate campaign attribution only). +- Unsubscribe links continue to function via `unsubscribe_token`. + +## 3. Data Retention and Erasure (Right to be Forgotten) +**Current State:** +- `analytics.jsonl` continuously appends telemetry data without a predefined rotation or deletion policy. +- Campaign interactions and `marketing_emails` records preserve history. + +**Deficiencies:** +- **Right to Erasure (Article 17):** If a user requested their data be deleted, the system would need to scan and purge their `userId` from the raw `analytics.jsonl` file. +- **Storage Limitation (Article 5):** PII should only be kept as long as necessary. + +**Recommendations:** +- **Structured Log Rotation:** Implement a background job that aggregates or rotates the `analytics.jsonl` file (e.g., deleting logs older than 90 days). +- **Deletion Workflows:** When a user invokes their right to be forgotten (or deletes their profile), ensure that cascading deletes cover the `contacts`, `contact_group_members`, and `marketing_emails` tables. + +## Summary +**Completed mitigations:** +- โœ… **IP anonymization** โ€” last octet zeroed in persisted logs; geo data preserved via pre-anonymization lookup. +- โœ… **PII minimization** โ€” `userEmail` and `userName` removed from JSONL logs; only `userId` retained. +- โœ… **Google Analytics removed** โ€” `gtag.js` stripped from `index.html`. No third-party cookies. +- โœ… **Per-contact email tracking disabled** โ€” `c_id` and `pixel.gif` commented out in `campaigns/index.ts`. + +**Remaining housekeeping (non-blocking):** +1. Automatically **rotate/expire** old unstructured analytics logs (recommend 90-day TTL). +2. Implement **deletion workflows** to purge `userId` from JSONL on erasure requests. diff --git a/packages/ui/docs/gmail.md b/packages/ui/docs/gmail.md new file mode 100644 index 00000000..2082977d --- /dev/null +++ b/packages/ui/docs/gmail.md @@ -0,0 +1,180 @@ +# Gmail / IMAP Integration + +Developer reference for connecting user mailboxes via IMAP to harvest contacts. + +--- + +## Overview + +Users can connect any IMAP mailbox (Gmail, Outlook, etc.) from the **Profile โ†’ Integrations** tab. Credentials are stored encrypted within `user_secrets.settings.mailboxes` in Supabase โ€” same row-level security as API keys. + +**Phase 1 (current):** Store credentials, test connection. +**Phase 2 (future):** Harvest sender/recipient contacts from mailbox, import into contact groups. +**Phase 3 (future):** OAuth2 flow for Gmail (no App Password needed). + +--- + +## Gmail App Password Setup (Required for Phase 1) + +Standard Gmail passwords won't work over IMAP if 2FA is enabled. Users must generate an **App Password**: + +1. Go to [myaccount.google.com/security](https://myaccount.google.com/security) +2. Enable 2-Step Verification if not already on +3. Go to **App passwords** โ†’ Select app: *Mail*, device: *Other (custom name)* +4. Copy the generated 16-character password + +IMAP settings for Gmail: +- **Host:** `imap.gmail.com` +- **Port:** `993` +- **TLS:** true (IMAPS) +- **Auth:** plain (user + App Password) + +--- + +## Credential Storage Schema + +Mailboxes are stored in `user_secrets.settings.mailboxes` as a JSON array: + +```ts +interface MailboxCredential { + id: string; // uuid, generated on save + label: string; // user-facing name e.g. "Work Gmail" + host: string; // imap.gmail.com + port: number; // 993 + tls: boolean; // always true for Gmail + user: string; // email@gmail.com + password: string; // App Password (stored as-is, protected by Supabase RLS) + status?: 'ok' | 'error' | 'pending'; + lastTestedAt?: string; // ISO datetime + lastError?: string; +} +``` + +No plaintext encryption beyond Supabase's column-level storage. The password is **never returned** from the API โ€” only `has_password: boolean` and `user` are exposed. + +--- + +## API Routes + +All routes require `Authorization: Bearer ` header. + +### `GET /api/contacts/mailboxes` +List connected mailboxes for the current user. Password is masked. + +**Response:** +```json +[ + { + "id": "uuid", + "label": "Work Gmail", + "host": "imap.gmail.com", + "port": 993, + "tls": true, + "user": "user@gmail.com", + "has_password": true, + "status": "ok", + "lastTestedAt": "2026-03-06T12:00:00Z" + } +] +``` + +### `POST /api/contacts/mailboxes` +Save or update a mailbox credential. + +**Body:** +```json +{ + "id": "optional-uuid-for-update", + "label": "Work Gmail", + "host": "imap.gmail.com", + "port": 993, + "tls": true, + "user": "user@gmail.com", + "password": "abcd efgh ijkl mnop" +} +``` + +**Response:** Masked mailbox object (same as GET item). + +### `DELETE /api/contacts/mailboxes/:id` +Remove a mailbox by ID. + +**Response:** `{ "ok": true }` + +### `POST /api/contacts/mailboxes/:id/test` +Test an IMAP connection using saved credentials. Does not modify stored data but updates `status` and `lastTestedAt`. + +**Response:** +```json +{ "ok": true } +// or +{ "ok": false, "error": "Invalid credentials" } +``` + +--- + +## Server Implementation + +Located in `server/src/products/contacts/`: + +- **`imap-handler.ts`** โ€” business logic using `imapflow` +- **`imap-routes.ts`** โ€” route definitions (Hono/Zod OpenAPI) +- **`index.ts`** โ€” routes registered in `ContactsProduct.initializeRoutes()` + +### imapflow connection pattern + +```ts +import { ImapFlow } from 'imapflow'; + +const client = new ImapFlow({ + host: creds.host, + port: creds.port, + secure: creds.tls, + auth: { user: creds.user, pass: creds.password }, + logger: false, +}); +await client.connect(); +await client.logout(); +``` + +--- + +## Environment Variables + +No additional env vars required for Phase 1 (credentials are per-user in Supabase). + +**Future OAuth2 vars (Phase 3):** +```env +# Google OAuth2 for Gmail IMAP access +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-client-secret +GOOGLE_REDIRECT_URI=https://your-domain.com/api/auth/google/callback +``` + +--- + +## Security Notes + +- Passwords are stored in `user_secrets` which has Supabase Row Level Security allowing only the owner to read their own row +- Server API never returns the password field โ€” only `has_password: true` +- Test connection is server-side only; credentials are never sent to the browser after initial save +- In future Phase 3, App Password flow is replaced by OAuth2 refresh tokens (no password stored at all) + +--- + +## Frontend + +Component: `src/components/GmailIntegrations.tsx` +Client module: `src/modules/contacts/client-mailboxes.ts` +Profile tab: `Profile.tsx` โ†’ `/profile/integrations` + +--- + +## Roadmap + +| Phase | Description | Status | +|-------|-------------|--------| +| 1 | Store IMAP credentials, test connection | โœ… Current | +| 2 | Harvest contacts from sent/received emails | ๐Ÿ”œ Planned | +| 3 | OAuth2 for Gmail (no App Password) | ๐Ÿ”œ Planned | +| 4 | Scheduled background sync, dedup contacts | ๐Ÿ”œ Planned | diff --git a/packages/ui/docs/gridsearch-v2.md b/packages/ui/docs/gridsearch-v2.md new file mode 100644 index 00000000..e45acc21 --- /dev/null +++ b/packages/ui/docs/gridsearch-v2.md @@ -0,0 +1,34 @@ +# GridSearch V2: Uniform Geometric Simulation to Backend Execution + +## The Problem (V1) +Our V1 backend architecture iterated directly over political GADM boundary nodes (e.g., cities, municipalities) and searched their geographic centers or raw bounding boxes. This methodology had three severe drawbacks: +1. **Gaps**: Political polygons are irregular. A fixed search radius originating from a polygon's centroid inevitably missed the edges and corners of oddly-shaped areas. +2. **Overlaps**: Densely packed suburban municipalities resulted in centroids sitting dangerously close to one another. The search radii overlapped, causing redundant API calls returning the exact same prospects. +3. **Empty Wastelands**: A single large municipality might be 80% uninhabited mountain ranges or deserts. Searching its center burned API credits on regions with zero B2B locations. + +## The V2 Solution & Architecture +In V2, we relegated GADM nodes to being **clipping masks** rather than search targets. The actual API "hops" happen across a mathematically uniform geometric grid that perfectly tiles the target terrain. + +This system guarantees **100% geographic coverage with 0% redundancy** and relies on a dual-stack architecture: + +### 1. The Client-Side UI & Simulator (Completed) +We constructed the `GridSearchPlayground`, `GadmPicker`, and `GridSearchSimulator` to visually tune and preview search parameters in the browser: +* The user selects specific hierarchical GADM geographies. +* The simulator overlays a Turf.js generated grid (Hex or GADM-native). +* Configurable optimization parameters (Max Elevation, Min Population Density) dynamically cull the grid in real time, preventing wasted API hops in uninhabited or extreme terrain. +* The simulator visualizes the path trajectory ("snake", "zigzag", "spiral-out") representing the exact sequence of planned API calls. + +### 2. The Backend Execution Engine (Next Steps) +The exact Grid generation and culling logic visually perfected in the frontend must now be translated into the Node.js API pipeline to actually execute the real GridSearches. + +**Porting Requirements:** +* **Payload Ingestion:** The server must accept the optimized parameters selected by the user (target regions/polygons, grid mode, cell size, path trajectory, filters). +* **Grid Computation (Server-Side Turf.js):** The backend will replicate the Turf.js bounding box, grid generation, intersection, and sorting logic to reconstruct the exact `validCells` array the UI simulator previewed. +* **Topographical Filtering:** Recreate the logic that drops cells failing the structural constraints (e.g., average elevation > threshold, population density < threshold). +* **Sequential API Execution:** Once the valid grid is ordered matching the trajectory, the backend will iterate over the cells using a queue (or sequential loop), rate-limiting the execution of the actual Provider API calls to scrape the specified coordinates. +* **Progress Tracking & Persistence:** Emit progress updates (e.g., via WebSockets or job tracking) marking cells as 'processed', saving scraped data back to the database, and ensuring the job can resume cleanly if interrupted. + +## Execution Flow Porting Plan +1. **API Design**: Define the structured endpoint payload `POST /api/locations/gridsearch/generate` capable of receiving the complex region + filter configuration. +2. **Modular Turf Utils**: Abstract the Turf.js grid logic (`turf.hexGrid`, intersections, centroid path sorting) into shared utility functions accessible by the backend worker. +3. **Workflow Integration**: Wire the resulting mathematically optimal coordinate arrays into the pre-existing grid search pipeline, effectively bridging the sophisticated V2 UI targeting with the core V1 scraping engine. diff --git a/packages/ui/docs/groups-acl.md b/packages/ui/docs/groups-acl.md new file mode 100644 index 00000000..3fb6b426 --- /dev/null +++ b/packages/ui/docs/groups-acl.md @@ -0,0 +1,197 @@ +# Groups & ACL โ€” Proposal + +## Problem + +The ACL system currently hacks `anonymous` and `authenticated` as **sentinel strings** mapped to `group_name` in [resource_acl](../supabase/migrations/20260219215000_create_resource_acl.sql). This works but: + +- No concept of custom groups (`vendors`, `editors`, etc.) +- Virtual user ID mapping in [db-acl-db.ts](../server/src/products/serving/db/db-acl-db.ts) is fragile +- No UI for managing groups or discovering group membership +- Can't answer "which groups does user X belong to?" or "who is in group Y?" + +## @polymech/acl Package + +The [acl package](../../polymech-mono/packages/acl/README.md) already has a proper RBAC system: + +| Concept | Implementation | +|---------|---------------| +| Core | [Acl.ts](../../polymech-mono/packages/acl/src/Acl.ts) โ€” Zend-inspired RBAC: `allow(roles, resources, perms)`, `isAllowed(user, resource, perms)` | +| Backend interface | [IBackend](../../polymech-mono/packages/acl/src/interfaces.ts) โ€” bucket-based key-value storage | +| Backends | [MemoryBackend](../../polymech-mono/packages/acl/src/data/MemoryBackend.ts), [FileBackend](../../polymech-mono/packages/acl/src/data/FileBackend.ts) | +| VFS layer | [vfs-acl.ts](../../polymech-mono/packages/acl/src/vfs/vfs-acl.ts), [AclVfsClient](../../polymech-mono/packages/acl/src/vfs/AclVfsClient.ts) | + +The `Acl` class already supports: +- `addUserRoles(userId, roles)` / `removeUserRoles(userId, roles)` +- `userRoles(userId)` โ†’ returns all roles for a user +- `roleUsers(role)` โ†’ returns all users in a role +- `addRoleParents(role, parents)` โ†’ role hierarchy +- `isAllowed(userId, resource, permissions)` โ†’ permission check + +**Groups = Roles in the ACL model.** The `Acl` class uses "roles" โ€” groups are just roles with membership. The missing piece is a **Supabase-backed backend** that persists role/group data in the DB instead of files/memory. + +## Current Architecture + +``` +pm-pics (app) @polymech/acl (package) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +resource_acl table Acl class +โ”œโ”€โ”€ user_id UUID FK โ”œโ”€โ”€ addUserRoles() +โ”œโ”€โ”€ group_name TEXT (hack) โ”œโ”€โ”€ isAllowed() +โ”‚ โ””โ”€โ”€ IBackend +db-acl-db.ts (DbAclBackend) โ”œโ”€โ”€ MemoryBackend +โ”œโ”€โ”€ sentinel mapping hack โ””โ”€โ”€ FileBackend +โ”œโ”€โ”€ rowToEntry / entryToRow +โ””โ”€โ”€ direct Supabase queries VFS extensions + โ”œโ”€โ”€ AclVfsClient +db-acl.ts (orchestrator) โ”œโ”€โ”€ vfs-acl.ts +โ”œโ”€โ”€ IAclBackend (app-level) โ””โ”€โ”€ uses FileBackend +โ”œโ”€โ”€ registerAclBackend() +โ””โ”€โ”€ fetchAclSettings / grant / revoke +``` + +The problem: `pm-pics` has its own `IAclBackend` + `DbAclBackend` that **duplicate** what `@polymech/acl` does, but with Supabase instead of files. The sentinel hack exists because `DbAclBackend` bypasses `Acl` entirely. + +## Proposed Architecture + +### New backend in @polymech/acl + +``` +@polymech/acl +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ data/ +โ”‚ โ”‚ โ”œโ”€โ”€ MemoryBackend.ts (existing) +โ”‚ โ”‚ โ”œโ”€โ”€ FileBackend.ts (existing) +โ”‚ โ”‚ โ””โ”€โ”€ SupabaseBackend.ts [NEW] โ€” IBackend using Supabase tables +โ”‚ โ”œโ”€โ”€ groups/ +โ”‚ โ”‚ โ”œโ”€โ”€ GroupManager.ts [NEW] โ€” CRUD for groups + membership +โ”‚ โ”‚ โ””โ”€โ”€ interfaces.ts [NEW] โ€” IGroupStore, Group, GroupMember +โ”‚ โ”œโ”€โ”€ Acl.ts (existing, no changes) +โ”‚ โ””โ”€โ”€ interfaces.ts (existing, no changes) +``` + +The key insight: **the `Acl` class doesn't need to change**. It already handles roles, users, and permissions generically. We just need: + +1. **`SupabaseBackend`** โ€” implements `IBackend` using Supabase tables instead of in-memory maps +2. **`GroupManager`** โ€” thin wrapper for creating/listing groups and managing membership (writes to `groups` + `group_members` tables) + +### Database Tables + +```sql +-- Groups definition +create table public.groups ( + id uuid not null default gen_random_uuid() primary key, + slug text not null unique, -- 'editors', 'vendors' + name text not null, -- 'Editors', 'Vendors' + description text, + builtin boolean not null default false, -- true for anonymous/authenticated + created_by uuid references auth.users(id), + created_at timestamptz default now() +); + +-- Seed built-in groups +insert into public.groups (slug, name, builtin) values + ('anonymous', 'Anonymous', true), + ('authenticated', 'Authenticated Users', true); + +-- Group membership (not needed for built-in groups) +create table public.group_members ( + id uuid not null default gen_random_uuid() primary key, + group_id uuid not null references public.groups(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text default 'member', + added_at timestamptz default now(), + unique(group_id, user_id) +); +``` + +### How It Connects + +``` +resource_acl.group_name โ”€โ”€referencesโ”€โ”€> groups.slug +groups.builtin = true โ†’ membership is implicit (auth state) +groups.builtin = false โ†’ membership via group_members +``` + +`resource_acl.group_name` stays as-is โ€” becomes a soft FK to `groups.slug`. + +### Resolution Flow + +``` +1. Load ACL entries for resource R +2. For each entry: + โ”œโ”€โ”€ entry.user_id === caller โ†’ direct user grant + โ”œโ”€โ”€ entry.group_name = 'anonymous' โ†’ builtin, match everyone + โ”œโ”€โ”€ entry.group_name = 'authenticated' && caller.authenticated โ†’ match + โ””โ”€โ”€ entry.group_name = 'vendors' โ†’ check group_members โ†’ match? +3. Any match with required permission โ†’ allow +``` + +### Relationship to user_roles + +> **Keep `user_roles` separate.** It's for system-level admin. Groups are for content/resource access control. + +## Files to Change + +### Phase 1 โ€” @polymech/acl package + +| File | Change | +|------|--------| +| [NEW] `packages/acl/src/groups/interfaces.ts` | `IGroupStore`, `Group`, `GroupMember` types | +| [NEW] `packages/acl/src/groups/GroupManager.ts` | CRUD: `fetchGroups`, `createGroup`, `addMember`, `removeMember`, `getGroupsForUser` | +| [MODIFY] [index.ts](../../polymech-mono/packages/acl/src/index.ts) | Export `GroupManager` + types | + +### Phase 2 โ€” pm-pics integration + +| File | Change | +|------|--------| +| [NEW] `supabase/migrations/xxx_create_groups.sql` | `groups` + `group_members` tables | +| [MODIFY] [db-acl-db.ts](../server/src/products/serving/db/db-acl-db.ts) | Remove sentinel hack, use `GroupManager` for membership checks | +| [MODIFY] [db-categories.ts](../server/src/products/serving/db/db-categories.ts) | `isCategoryVisible()` uses group membership lookup | +| [MODIFY] [db-acl.ts](../server/src/products/serving/db/db-acl.ts) | Group-aware permission resolution | +| [MODIFY] [index.ts](../server/src/products/serving/index.ts) | Register group API routes | +| [NEW] `server/src/products/serving/db/db-groups.ts` | Handlers for groups API | +| [NEW] `src/modules/groups/client-groups.ts` | Client API | + +### Phase 3 โ€” UI + +| File | Change | +|------|--------| +| [MODIFY] [AclEditor.tsx](../src/components/admin/AclEditor.tsx) | Dynamic group picker from `groups` table | +| [NEW] `src/components/admin/GroupManager.tsx` | Admin UI: create groups, manage members | +| [MODIFY] [CategoryManager.tsx](../src/components/widgets/CategoryManager.tsx) | Group picker in permissions | + +## API Design + +``` +GET /api/groups โ†’ list all groups +POST /api/groups โ†’ create group (admin) +PATCH /api/groups/:id โ†’ update group (admin) +DELETE /api/groups/:id โ†’ delete group (admin, not builtin) + +GET /api/groups/:id/members โ†’ list members +POST /api/groups/:id/members โ†’ add member +DELETE /api/groups/:id/members/:userId โ†’ remove member + +GET /api/users/:id/groups โ†’ groups for a user (self or admin) +``` + +## AclEntry Type Evolution + +```diff + interface AclEntry { +- userId?: string; // real UUID or 'anonymous'/'authenticated' (hack) +- group?: string; // unused for virtual IDs ++ userId?: string; // real UUID only ++ groupSlug?: string; // 'anonymous', 'authenticated', 'vendors', etc. + path?: string; + permissions: string[]; + } +``` + +## Priority + +| Phase | Effort | Enables | +|-------|--------|---------| +| 1. @polymech/acl groups | ~2h | Reusable group system for any app | +| 2. pm-pics integration | ~2h | Category/resource visibility by group | +| 3. UI | ~3h | Admin can manage groups visually | diff --git a/packages/ui/docs/layoutcontainer-ex.md b/packages/ui/docs/layoutcontainer-ex.md index 0d611ed1..ccf9bffd 100644 --- a/packages/ui/docs/layoutcontainer-ex.md +++ b/packages/ui/docs/layoutcontainer-ex.md @@ -543,6 +543,194 @@ In Tailwind, this is `max-md:!grid-cols-1` (with `!` for `!important` to overrid --- +## Email Rendering Compatibility + +### How Email Rendering Currently Works + +`pages-email.ts` renders page layouts to email HTML server-side. The pipeline: + +1. **Load HTML templates** โ€” `image_xl.html`, `image_col_2.html`, `image_col_3.html`, `section_text.html`, etc. +2. **Walk containers** โ€” `renderContainer()` iterates widgets sorted by order. +3. **Group by columns** โ€” If `container.columns === 2` or `3`, widgets are chunked and rendered via the matching column template. Everything else โ†’ single-column (`image_xl.html`). +4. **Inline CSS** โ€” `juice` inlines all CSS for email client compatibility. + +### Current Column Templates + +The 2-column template (`image_col_2.html`): +```html + + + + +
+ +
+ +
+ +
+ +
+ +
+``` + +**Pattern:** Outer `` wrapper โ†’ `
` elements with `inline-block` styling (via CSS) for modern clients + MSO conditional `
` for Outlook. Fixed `width="50%"` / `width="33%"` โ€” **no arbitrary widths**. + +### What Email Clients Support + +| Feature | Gmail | Outlook (MSO) | Apple Mail | Yahoo | +|---------|-------|---------------|------------|-------| +| `
` layout | โœ… | โœ… | โœ… | โœ… | +| `display: inline-block` | โœ… | โŒ (needs MSO table) | โœ… | โœ… | +| `display: grid` | โŒ | โŒ | โŒ | โŒ | +| `display: flex` | โŒ | โŒ | Partial | โŒ | +| Arbitrary `width` on `
` | โœ… (%) | โœ… (%) | โœ… (%) | โœ… (%) | +| `fr` units | โŒ | โŒ | โŒ | โŒ | +| `px` widths on `` | โœ… | โœ… | โœ… | โœ… | +| `max-width` media queries | โœ… | โŒ | โœ… | โœ… | +| `align-items`, `align-self` | โŒ | โŒ | โŒ | โŒ | + +> [!CAUTION] +> **CSS Grid and Flexbox are NOT usable in email.** The row-layout's frontend CSS Grid rendering has zero overlap with email rendering. Email must use `` layout exclusively. + +### Strategy: Dynamic Table Generation for Row-Layouts + +Instead of pre-built templates, row-layouts need **dynamically generated table HTML** on the server. The `fr` โ†’ `%` conversion is straightforward: + +```typescript +// Convert ColumnDef[] to percentage widths for email tables +function columnsToPercentages(columns: ColumnDef[]): number[] { + const totalFr = columns + .filter(c => c.unit === 'fr') + .reduce((sum, c) => sum + c.width, 0); + + // Fixed columns (px) get a fixed width, remaining space split by fr + // For email simplicity: convert everything to percentages + const total = columns.reduce((sum, c) => { + if (c.unit === '%') return sum + c.width; + if (c.unit === 'fr') return sum + (c.width / totalFr) * 100; + // px: approximate against 600px email width + return sum + (c.width / 600) * 100; + }, 0); + + return columns.map(c => { + if (c.unit === '%') return c.width; + if (c.unit === 'fr') return (c.width / totalFr) * 100; + return (c.width / 600) * 100; + }).map(p => Math.round(p * 100) / 100); // round to 2dp +} +``` + +### Generating Row HTML + +Each row becomes a `
` with `
` elements at computed percentages: + +```typescript +function renderRowToEmailHtml(row: RowDef, cellContents: string[]): string { + const widths = columnsToPercentages(row.columns); + + // MSO conditional table for Outlook + const msoStart = ``; + + let html = `
`; + html += ``; + + widths.forEach((w, i) => { + if (i > 0) { + html += ``; + } + html += ``; + html += `
`; + html += cellContents[i] || ''; + html += `
`; + }); + + html += ``; + html += `
`; + return html; +} +``` + +### What Changes in `pages-email.ts` + +The existing `renderContainer()` handles `type: 'container'`. A new `renderRowLayout()` function handles `type: 'row-layout'`: + +```typescript +async function renderRowLayout(container: RowLayoutContainer) { + let html = ''; + + for (const row of container.rows) { + // Get widgets for each cell + const cellContents: string[] = []; + + for (let colIdx = 0; colIdx < row.columns.length; colIdx++) { + const cellWidgets = container.widgets + .filter(w => w.rowId === row.id && w.column === colIdx) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + let cellHtml = ''; + for (const widget of cellWidgets) { + cellHtml += await renderWidget(widget); + } + cellContents.push(cellHtml); + } + + html += renderRowToEmailHtml(row, cellContents); + } + + return html; +} +``` + +Then in the main rendering loop: + +```typescript +for (const container of sortedRootContainers) { + if (container.type === 'row-layout') { + contentHtml += await renderRowLayout(container); + } else { + contentHtml += await renderContainer(container); // existing path + } +} +``` + +### Sizing Mode Mapping in Email + +| Frontend sizing | Email equivalent | Notes | +|----------------|-----------------|-------| +| **Constrained** (`items-stretch`) | `valign="top"` + equal row height | Email doesn't support equal-height columns natively. Closest: `valign="top"` (cells align to top, height varies). True equal-height requires background tricks or is simply accepted as a limitation. | +| **Unconstrained** (`items-start`) | `valign="top"` | Natural in email โ€” cells are always content-height. | +| **Per-cell alignment** | `valign="top"` / `"middle"` / `"bottom"` | Maps directly to `
`. Works everywhere including MSO. | +| **`fr` widths** | Converted to `%` | `[1fr, 2fr, 1fr]` โ†’ `[25%, 50%, 25%]` | +| **`px` widths** | Converted to `%` against 600px | `250px` โ†’ `41.67%`. Or use fixed `width="250"` on `` and let remaining cells flex. | +| **Mobile collapse** | `@media (max-width: 480px)` | Supported by Gmail, Apple Mail, Yahoo. NOT Outlook โ€” Outlook always shows desktop layout. | + +### Limitations & Tradeoffs + +| Issue | Severity | Mitigation | +|-------|----------|-----------| +| **No equal-height columns** in email | Low | Most content renders fine with `valign="top"`. Background-color tricks exist but add complexity. | +| **Outlook ignores media queries** | Medium | Outlook always renders at desktop width. Acceptable โ€” newsletter emails typically have a 600px fixed width anyway. | +| **Mixed `px`/`fr` approximation** | Low | Converting to `%` is imperfect but good enough for email. The 600px assumption is standard for email templates. | +| **Widget rendering fallback** | Low | Any widget not supported by `renderWidget()` already falls through silently. Row-layout just needs the column wrapper logic. | +| **New templates NOT needed** | Nice | Unlike the current approach with pre-built `image_col_2.html` / `image_col_3.html`, the dynamic table generator handles any column count. The pre-built templates can remain for the old `LayoutContainer` path. | + +### Summary + +โœ… **Yes, row-layouts can be rendered to email.** The approach is: + +1. Convert `ColumnDef[]` widths to `%` values +2. Generate `
` dynamically per row +3. MSO conditional comments for Outlook compatibility +4. `valign` for vertical alignment +5. Reuse existing `renderWidget()` for cell content +6. ~80 lines of new code in `pages-email.ts` + +--- + ## Impact Analysis ### ๐ŸŸข No-impact (if we keep old `LayoutContainer` as-is) diff --git a/packages/ui/docs/layoutcontainer-todos.md b/packages/ui/docs/layoutcontainer-todos.md new file mode 100644 index 00000000..ffeac7ac --- /dev/null +++ b/packages/ui/docs/layoutcontainer-todos.md @@ -0,0 +1,48 @@ +# Flexible Container โ€” Implementation Todos + +## Phase 1: Data Model & Types +- [x] Add `ColumnDef`, `RowDef`, `FlexibleContainer` types to `LayoutManager.ts` +- [x] Add Zod schemas to `schemas.ts` (`ColumnDefSchema`, `RowDefSchema`, `FlexibleContainerSchema`, `AnyContainerSchema`) +- [x] Update `PageLayout.containers` to `AnyContainer[]` union type +- [x] Add `rowId`, `column` fields to `WidgetInstance` for cell placement +- [x] Add type guards (`isLayoutContainer`, `isFlexibleContainer`, `getLayoutContainers`) +- [x] Add `findLayoutContainer()`, `findFlexContainer()` narrowing helpers +- [x] Add `generateRowId()`, `addFlexibleContainer()` methods +- [x] Fix all internal closures in `LayoutManager.ts` to handle `AnyContainer[]` + +## Phase 2: Renderer Component +- [x] Create `FlexibleContainerRenderer.tsx` โ€” CSS Grid row-based renderer +- [x] Integrate type-based dispatch in `GenericCanvas.tsx` +- [x] Fix all downstream type errors (`UserPageEdit.tsx`, `GenericCanvas.tsx`) + +## Phase 3: UI Integration & Add Button +- [x] Quick-add buttons in `GenericCanvas.tsx` empty state +- [x] `AddFlexContainerCommand` in `commands.ts` (with undo) +- [x] `addFlexPageContainer` method in `LayoutContext.tsx` +- [x] `handleAddFlexContainer` handler in `UserPageEdit.tsx` +- [x] CommandPicker palette entry (Ctrl+K โ†’ "Add Flexible Container") +- [x] Ribbon bar "Flex Container" button (purple icon, `Columns`) +- [x] Update `commands.ts` helpers for `AnyContainer[]` support + +## Phase 4: Row/Column Commands +- [x] `FlexAddRowCommand` โ€” adds row with 2 equal columns +- [x] `FlexRemoveRowCommand` โ€” removes row (with state capture for undo) +- [x] `FlexAddColumnCommand` โ€” adds 1fr column to row +- [x] `FlexRemoveColumnCommand` โ€” removes column by index (with state capture) +- [x] Wire commands to `FlexibleContainerRenderer` UI buttons via `executeCommand` + +## Phase 5: Edit-Mode UI Polish +- [ ] Column drag handles for width adjustment +- [ ] Column width presets dropdown +- [ ] Row reordering (move up/down) + +## Phase 6: Email Rendering +- [ ] Add `renderFlexibleContainer()` to `pages-email.ts` +- [ ] `columnsToPercentages()` utility +- [ ] Dynamic `` generation with MSO conditionals + +## Phase 7: Iteration & Polish +- [ ] Settings panel for row sizing mode (constrained/unconstrained) +- [ ] Per-cell alignment overrides +- [ ] Mobile responsive collapse +- [ ] PDF rendering support diff --git a/packages/ui/docs/locations/algo.md b/packages/ui/docs/locations/algo.md new file mode 100644 index 00000000..51ae1d4d --- /dev/null +++ b/packages/ui/docs/locations/algo.md @@ -0,0 +1,50 @@ +# Search Algorithm Strategies + +## 1. Zoom Level Calibration +**Objective**: Determine the optimal zoom level to maximize the number of unique locations found for a given keyword and location. + +### Strategy +1. **Input**: + - Keyword (e.g., "carpenters") + - Location (e.g., "Barcelona, Spain") + - Zoom Range (e.g., 12-18) + +2. **Process**: + - Iterate through the defined range of zoom levels. + - For each zoom level: + - Perform a Google Maps search using the `googleMaps` function. + - Store the results in a JSON file using the `--dst` option (e.g., `./tmp/search/test-zoomlevel-.json`). + - Count the total number of valid results returned. + - Maintain a record of (Zoom Level -> Result Count). + +3. **Output**: + - The zoom level that yielded the maximum number of results. + +4. **Notes**: + - This process assumes that for a fixed point, the "best" zoom captures the most relevant density without being too broad (losing small entities) or too narrow (missing context). + - Overlaps/Duplicates should be handled by the underlying search function or post-processing if multi-point scanning is used later. + +## 2. Area Scanning (Grid Search) +**Objective**: Scan a larger, defined area (e.g., "Madrid" or "Spain") using the optimal zoom level to ensure comprehensive coverage. + +### Strategy (Planning) +1. **Input**: + - Target Area Boundaries (Polygon/Box). + - Calibrated Zoom Level (from Step 1). + - List of Provinces/Towns (if segmenting by admin regions). + +2. **Grid Generation**: + - Determine the lat/long delta that corresponds to the calibrated zoom level's viewport size. + - Create a grid of search coordinates covering the Target Area. + +3. **Execution & State Management**: + - This is a long-running task. + - **State Store**: Maintain a persistent state (JSON/DB) tracking: + - Queue of pending coordinates. + - Completed coordinates. + - Failed coordinates. + - Process the queue sequentially or in parallel batches. + +4. **Aggregation**: + - Combine all result files. + - Perform global deduplication (by `place_id` or `title` + `address`). diff --git a/packages/ui/docs/locations/enricher.md b/packages/ui/docs/locations/enricher.md new file mode 100644 index 00000000..ee42672f --- /dev/null +++ b/packages/ui/docs/locations/enricher.md @@ -0,0 +1,114 @@ +# Enricher System Design + +## Overview + +We are separating the "enrichment" logic (scraping, email finding, etc.) from the core search library (`@polymech/search`) to create a modular, extensible system within the server. This system will support both on-demand discovery (fast initial results + streaming enrichment) and batch processing. + +## Goals + +1. **Decouple:** Move enrichment logic out of `googlemaps.ts`. +2. **Performance:** Allow fast initial search results (meta=false) with lazy loading for enrichment. +3. **Extensibility:** Registry-based system to easily swap or add enrichers (e.g., 'local', 'outsource'). +4. **Streaming:** Centralized streaming hub to emit enrichment updates to the client. + +## Architecture + +### 1. The Enricher Interface + +Each enricher must implement a standard interface. + +```typescript +export interface EnrichmentContext { + userId: string; + // ... potentially other context +} + +export interface IEnricher { + name: string; + type: 'meta' | 'email' | 'phones' | string; + + /** + * Enrich a single location. + * @param location The partial competitor data available + * @param context Execution context + */ + enrich(location: CompetitorFull, context: EnrichmentContext): Promise>; +} +``` + +### 2. Registry + +A simple registry to manage available enrichers. + +```typescript +export class EnricherRegistry { + private static enrichers: Map = new Map(); + + static register(name: string, enricher: IEnricher) { + this.enrichers.set(name, enricher); + } + + static get(name: string): IEnricher | undefined { + return this.enrichers.get(name); + } + + static getAll(): IEnricher[] { + return Array.from(this.enrichers.values()); + } +} +``` + +### 3. Implementation: 'Local' Meta Enricher + +We will port the scraping logic from `search/src/lib/html.ts` to `server/src/products/locations/enrichers/local-meta.ts`. + +* **Logic:** Puppeteer/Axios based scraping. +* **Target:** Updates `raw_data.meta`, and extracts social links/emails to `CompetitorSchemaFull` fields. +* **Adjustments:** Ensure strictly server-side dependencies are used and handle errors gracefully without crashing the stream. + +### 4. Streaming Hub + +A new endpoint `/api/competitors/enrich/stream` (or integrated into existing stream logic) that allows the client to request enrichment for specific items. + +**Request:** + +```json +{ + "place_ids": ["..."], + "enrichers": ["meta"] +} +``` + +**Flow:** + +1. Verify usage/credits. +2. For each `place_id`: + * Load current data. + * Run requested enrichers (concurrently or sequentially). + * Emit `enrichment-update` SSE event with the diff/new data. + * Persist updates to DB. + +## Data Schema Extensions + +We will extend `CompetitorSchemaFull` (via `raw_data` or explicit fields) to hold the enrichment results. + +* `meta`: Object containing scraping results (title, description, og-tags). +* `social`: Standardized social profile links. +* `emails`: Discovered emails. + +## Phasing + +### Phase 1: Meta Enricher & Registry + +* Create `EnricherRegistry`. +* Port `html.ts` to `server/src/products/locations/enrichers/meta.ts`. +* Setup the streaming endpoint for "meta" enrichment. + +### Phase 2: Email Enricher + +* Implement 'email' enricher (likely using existing logic or new providers). + +### Phase 3: Client Integration + +* Update client to fetch search results *without* meta first. +* Trigger enrichment stream for visible/requested items. diff --git a/packages/ui/docs/locations/gadm.md b/packages/ui/docs/locations/gadm.md new file mode 100644 index 00000000..6d97405c --- /dev/null +++ b/packages/ui/docs/locations/gadm.md @@ -0,0 +1,82 @@ +# GADM Integration Documentation + +## Overview + +We use the [GADM (Database of Global Administrative Areas)](https://gadm.org/) as our source of truth for administrative boundaries (GeoJSON). This allows us to perform "Regional Scanning" by defining precise polygons for irregular areas like cities, provinces, and states. + +## Data Structure + +GADM organizes areas hierarchically. + +- **Level 0**: Country (e.g., `ESP` for Spain) +- **Level 1**: Primary subdivision (e.g., "Catalunya" - Region) +- **Level 2**: Secondary subdivision (e.g., "Barcelona" - Province) +- **Level 3+**: Tertiary (e.g., Municipalities) + +Every area has a unique **GID** (GADM ID): + +- `ESP` (Spain) +- `ESP.5_1` (Catalunya) +- `ESP.5.1_1` (Barcelona) + +> **Note**: GADM codes are *not* standard ISO codes. Always rely on **Name Search** to find the correct GID. + +--- + +## API Endpoints + +We expose a set of public endpoints to interface with the local `pygadm` wrapper. + +### 1. Search Regions + +Search for a region by name to find its metadata (GID, Name, Type) or full geometry. + +`GET /api/regions/search` + +| Parameter | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `query` | string | Yes | Name to search for (e.g., "France", "Paris") | +| `content_level` | int | No | Filter by admin level (e.g., `1` for regions) | +| `geojson` | boolean | No | If `true`, returns full `FeatureCollection` with geometry. | + +**Example:** +`/api/regions/search?query=Catalunya&content_level=1` + +### 2. Get Boundary + +Retrieve the precise GeoJSON boundary for a specific known GID. + +`GET /api/regions/boundary/{id}` + +| Parameter | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `id` | string | Yes | GADM ID (e.g., `FRA.1_1`) | + +**Response:** +Returns a GeoJSON `FeatureCollection` containing the polygon(s) for that region. + +### 3. Get Sub-Region Names + +List all child regions for a given parent code. Useful for cascading dropdowns. + +`GET /api/regions/names` + +| Parameter | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `admin` | string | Yes | Parent Admin Code (e.g., `FRA` or `FRA.1_1`) | +| `content_level` | int | Yes | The target level to retrieve (e.g., `2`) | + +--- + +## Mapping Strategy: SerpAPI to GADM + +External data sources like Google Maps (via SerpAPI) often use different standards (ISO-3166-2) than GADM. **Do not try to map by Code.** + +**Recommended Workflow:** + +1. **Extract Name**: Get the administrative name from the external result (e.g., `geo.principalSubdivision` -> "Catalunya"). +2. **Search GADM**: Search for this name using the endpoint. + - `GET /api/regions/search?query=Catalunya` +3. **Filter Results**: + - Match `GID_0` to the known Country Code (e.g., `ESP`) to resolve ambiguity (e.g., "Valencia" exists in Spain and Venezuela). +4. **Get Boundary**: Use the resulting `GID` (e.g., `ESP.5_1`) to fetch the polygon. diff --git a/packages/ui/docs/locations/grid-search-ex.md b/packages/ui/docs/locations/grid-search-ex.md new file mode 100644 index 00000000..8a8074ad --- /dev/null +++ b/packages/ui/docs/locations/grid-search-ex.md @@ -0,0 +1,268 @@ +# Grid Search โ€” Implementation Plan + +## Core Insight + +The grid search is a **GADM tree walk** with a **pluggable iterator**. + +Two distinct phases: + +1. **Enumerate** โ€” walk the GADM tree to the target level โ†’ return area names + centers (free, cached) +2. **Search** โ€” plug in an iterator function (Google Maps, email, โ€ฆ) that runs per area (costs credits) + +The user sees the area list first. Only when they confirm, the iterator runs. Results are cached per area โ€” re-walking skips already-searched areas. + +--- + +## Data Flow + +``` +Phase 1: Enumerate (no cost) Phase 2: Search (iterator) +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +region: "Spain, Catalonia" iterator: googleMapsSearch +level: "cities" types: ["machine shop"] + + โ”Œโ”€ searchRegions("Catalonia") for each area in areas: + โ”‚ โ†’ GID = "ESP.6_1" (L1) searchGoogleMap( + โ”‚ type, + โ”œโ”€ getRegionNames(admin=GID, contentLevel=3) @center.lat,lon,14z + โ”‚ โ†’ ["Barcelona", "Terrassa", "Sabadell", โ€ฆ] ) + โ”‚ โ†’ results[] + โ”œโ”€ getBoundaryFromGpkg(GID, 3) + โ”‚ โ†’ GeoJSON per city deduplicate by place_id + โ”‚ cache per area GID+type + โ””โ”€ centroid(bbox) โ†’ { lat, lon } + โ†’ GridArea[] + + Return to user: "Found 42 cities in Catalonia. + Run location search?" โ†’ user confirms โ†’ Phase 2 +``` + +--- + +## Level Mapping + +| User Level | GADM | Typical Meaning | +|-------------|------|-----------------------------| +| `provinces` | 1 | State / Province / Region | +| `districts` | 2 | County / District / Kreis | +| `cities` | 3 | Municipality / City | +| `towns` | 4 | Town / Commune | +| `villages` | 5 | Village / Sub-commune | + +> Not all countries have all levels. Function caps at the country's max depth. + +--- + +## API Design + +### Types + +```typescript +// server/src/products/locations/gridsearch-googlemaps.ts + +/** A resolved area from the GADM tree walk */ +export interface GridArea { + name: string; + gid: string; + level: number; + center: { lat: number; lon: number }; +} + +/** Phase 1 result โ€” just the enumerated areas */ +export interface GridEnumerateResult { + region: { name: string; gid: string; level: number }; + areas: GridArea[]; + maxLevelAvailable: number; +} + +/** Phase 2 result โ€” per-area search output */ +export interface GridSearchAreaResult { + area: GridArea; + results: any[]; + cached: boolean; + error?: string; +} + +/** Phase 2 full result */ +export interface GridSearchResult { + region: { name: string; gid: string; level: number }; + areaCount: number; + totalResults: number; + results: any[]; // deduplicated + areas: GridSearchAreaResult[]; + durationMs: number; +} +``` + +### Functions + +```typescript +/** + * Phase 1: Walk GADM tree, enumerate areas at target level. + * No SerpAPI calls, no cost. Results are cached. + */ +export async function gridEnumerate(opts: { + region: string; // "Spain, Catalonia" + level: GridLevel; // 'cities' | 'towns' | number +}): Promise + +/** + * Phase 2: Run Google Maps search on each area. + * This is the iterator โ€” pluggable per search type. + */ +export async function gridSearchGoogleMaps(opts: { + areas: GridArea[]; + types: string[]; + apiKey: string; + bigdata?: { key: string }; + limitPerArea?: number; // default: 20 + zoom?: number; // default: 14 + concurrency?: number; // default: 2 +}): Promise +``` + +### Separation of Concerns + +``` +gridEnumerate() โ†’ pure GADM, no cost, cacheable +gridSearchGoogleMaps() โ†’ takes areas[], fires SerpAPI, costs credits +``` + +Later iterators can follow the same pattern: +- `gridSearchEmails(areas, opts)` โ€” find emails per area +- `gridSearchEnrich(areas, opts)` โ€” run enrichers per area + +--- + +## Step-by-Step Logic + +### `gridEnumerate()` + +1. Split `region` on comma: `["Spain", "Catalonia"]` +2. Last part = `name`, first = `country` hint +3. `searchRegions({ query: name, country })` โ†’ get GID + level +4. Map level label โ†’ number (`cities=3`) +5. `getRegionNames({ admin: gid, contentLevel })` โ†’ rows with NAME/GID +6. `getBoundaryFromGpkg(gid, contentLevel)` โ†’ GeoJSON features +7. Compute bbox centroid per feature โ†’ `GridArea[]` + +### `gridSearchGoogleMaps()` + +1. For each area, build `searchCoord: @lat,lon,{zoom}z` +2. For each type in `types[]`: + - `searchGoogleMap(type, apiKey, opts)` โ†’ results +3. Merge results per area +4. Deduplicate globally by `place_id` +5. Return `GridSearchResult` + +--- + +## Center Computation (No Turf.js) + +```typescript +function bboxCentroid(feature: any): { lat: number; lon: number } { + let minLat = Infinity, maxLat = -Infinity; + let minLon = Infinity, maxLon = -Infinity; + const walk = (coords: any) => { + if (typeof coords[0] === 'number') { + const [lon, lat] = coords; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + return; + } + for (const c of coords) walk(c); + }; + walk(feature.geometry.coordinates); + return { lat: (minLat + maxLat) / 2, lon: (minLon + maxLon) / 2 }; +} +``` + +--- + +## File Structure + +``` +server/src/products/locations/ +โ”œโ”€โ”€ gridsearch-googlemaps.ts # [NEW] gridEnumerate + gridSearchGoogleMaps +โ”œโ”€โ”€ __tests__/ +โ”‚ โ””โ”€โ”€ gridsearch-googlemaps.e2e.test.ts # [NEW] E2E tests +``` + +--- + +## Test Plan + +```typescript +describe('Grid Search', () => { + + describe('Phase 1 โ€” Enumerate', () => { + it('enumerates Catalonia cities', async () => { + const result = await gridEnumerate({ + region: 'Spain, Catalonia', + level: 'cities', + }); + expect(result.areas.length).toBeGreaterThan(0); + expect(result.areas[0].center.lat).toBeTypeOf('number'); + // No SerpAPI calls, no cost + }); + + it('enumerates Sachsen districts', async () => { + const result = await gridEnumerate({ + region: 'Germany, Sachsen', + level: 'districts', + }); + expect(result.areas.length).toBe(13); + }); + }); + + describe('Phase 2 โ€” Google Maps Search', () => { + it('searches machine shops in 2 Catalonia cities', async () => { + const enumResult = await gridEnumerate({ + region: 'Spain, Catalonia', + level: 'cities', + }); + + // Only search first 2 areas to keep test cheap + const result = await gridSearchGoogleMaps({ + areas: enumResult.areas.slice(0, 2), + types: ['machine shop'], + apiKey: config.serpapi.key, + bigdata: config.bigdata, + limitPerArea: 5, + concurrency: 1, + }); + + expect(result.totalResults).toBeGreaterThan(0); + expect(result.areas[0].area.name).toBeDefined(); + }, 120_000); + }); +}); +``` + +### NPM Script + +```json +"test:products:locations:gridsearch:googlemaps": "vitest run src/products/locations/__tests__/gridsearch-googlemaps.e2e.test.ts" +``` + +--- + +## Caching Strategy + +| What | Cache Key | Storage | +|------|-----------|---------| +| Tree enumerate | `grid_enum_{gid}_{level}` | GADM file cache | +| Google Maps search | `grid_search_{gid}_{type}_{zoom}` | Supabase `place_searches` | +| Area boundaries | `boundary_{gid}` | GADM file cache (already cached) | + +--- + +## Future Iterators (Not Phase 1) + +- `gridSearchEmails(areas)` โ€” find emails for businesses found in each area +- `gridSearchEnrich(areas)` โ€” run meta/social enrichers per area +- PgBoss campaign integration โ€” one child job per area +- SSE streaming โ€” live progress as each area completes +- Cost estimation pre-flight โ€” `areaCount ร— costPerSearch` diff --git a/packages/ui/docs/locations/gridsearch.md b/packages/ui/docs/locations/gridsearch.md new file mode 100644 index 00000000..259cdf30 --- /dev/null +++ b/packages/ui/docs/locations/gridsearch.md @@ -0,0 +1,163 @@ +# Grid Search / Regional Scanning Documentation + +## Overview + +The Grid Search (or Regional Scanning) feature automates the discovery of leads across large, irregular geographic areas (e.g., entire cities, provinces, or countries). Instead of manual point searches, users select a defined administrative region, and the system intelligently decomposes it into a grid of optimal search points. + +This functionality relies on a microservice architecture where **GADM** (Global Administrative Areas) data provides high-fidelity GeoJSON boundaries for exclusion/inclusion logic. + +--- + +## Conceptual Architecture + +### 1. Region Selection (Client) + +The user select a target region (e.g., "รŽle-de-France, France"). The client fetches the corresponding boundary polygon from the GADM microservice (Admin Level 1/2). + +### 2. Grid Decomposition (Server/Client) + +The system calculates a "Search Grid" overlaying the target polygon. + +- **Viewport Normalization**: A single API search at Zoom Level 15 covers roughly a 2-5km radius. +- **Bounding Box**: A rectangular grid is generated covering the polygon's extents. +- **Point-in-Polygon Filtering**: Grid centers falling *outside* the actual administrative boundary (e.g., ocean, neighboring states) are discarded using spatial analysis libraries (e.g., `Turf.js`). + +### 3. Campaign Orchestration (Server) + +The resulting set of valid coordinates (e.g., 450 points) is submitted as a **"Scan Campaign"**. + +- **Batching**: The server does NOT run 450 searches instantly. It uses `PgBoss` to queue them as individual jobs. +- **Concurrency**: Jobs are processed with strict rate-limiting to respect SerpAPI quotas. +- **Deduplication**: Results from overlapping grid circles are merged by `place_id`. + +--- + +## Workflow Implementation + +### Step 1: User Selects Region + +User interactions with the new "Region Search" UI: + +1. **Search**: "California" +2. **Dropdown**: Selects "California, USA (State/Province)" +3. **Preview**: Map validates the polygon overlay. + +### Step 2: Grid Generation Status + +Pre-flight check displayed to user: + +- **Total Area**: 423,970 kmยฒ +- **Grid Density**: High (Zoom 15) +- **Estimated Points**: ~8,500 scans (Warn: Expensive!) +- **Cost**: 8,500 Credits +- **Action**: "Confirm & Start Campaign" + +### Step 3: Campaign Execution + +Server receives payload: + +```json +{ + "regionId": "USA.5_1", + "query": "Plumbers", + "gridConfig": { "zoom": 15, "overlap": 0.2 } +} +``` + +Server decomposes to jobs `[Job_1, Job_2, ... Job_8500]`. + +### Step 4: Live Updates + +The existing SSE stream (`stream-sse`) adapts to listen for Campaign Events, updating a global progress bar: + +- "Scanned 120/8500 sectors..." +- "Found 45 new leads..." + +--- + +## Implementation TODO List + +### Server-Side (`test/server`) + +- [x] **GADM Integration Endpoint**: + - [x] Create route `GET /api/regions/search?q={name}` to proxy requests to the GADM microservice or query local PostGIS. + - [x] Create route `GET /api/regions/boundary/{gadm_id}` to retrieve full GeoJSON. + - [x] Create route `GET /api/regions/names?admin={code}` to fetch sub-region names. +- [ ] **Grid Logic**: + - Install `@turf/turf` for geospatial operations. + - Implement `generateGrid(boundaryFeature, zoomLevel)` function: + - Calculate `bbox`. + - Generate point grid. + - Filter `pointsWithinPolygon`. +- [ ] **Campaign Manager**: + - Create `CampaignsProduct` or extend `LocationsProduct`. + - New Job Type: `REGION_SCAN_PARENT` (decomposes into child jobs). + - New Job Type: `REGION_SCAN_CHILD` (actual search). +- [ ] **Job Queue Optimization**: + - Ensure `PgBoss` allows huge batch insertions (thousands of jobs). + - Implement "Campaign Cancellation" (kill switch for all child jobs). + +### Client-Side (`test/client`) + +- [ ] **Region Picker UI**: + - New Autocomplete component fetching from `/api/regions/search`. +- [ ] **Map Visualization**: + - Render the GeoJSON `Polygon` on MapLibre. + - Render the calculated `Point` grid pre-flight (allow user to manually deselect points?). +- [ ] **Campaign Dashboard**: + - New View: "Active Scans". + - Progress bars per campaign. + - "Pause/Resume" controls. +- [ ] **Result Merging**: + - Ensure the client DataGrid can handle streaming results effectively from potentially thousands of searches (Virtualization required). + +--- + +## Existing Endpoint Reference + +*(Ref. `src/products/locations/index.ts`)* + +The current `LocationsProduct` is well-poised to be the parent of this logic. + +- **`handleStreamGet`**: Can be adapted to accept a `campaignId` instead of a single `location`. +- **`handleStreamEmail`**: Shows the pattern for batch processing (accepting arrays of IDs). We can replicate this "Scatter-Gather" pattern for the Region Scan. + +### Proposed GeoJSON Microservice Interface + +We assume the existence of an internal service (or creating a dedicated module) exposing: + +- `GET /gadm/v1/search?text=...` -> Returns lightweight metadata (ID, Name, Level). +- `GET /gadm/v1/feature/{id}` -> Returns heavy GeoJSON Geometry. + +--- + +## 4. Potential Data Enrichments + +To increase the value of harvested locations, the following layers can be overlaid or merged with the search results: + +### Demographics & Population + +- **WorldPop**: High-resolution raster data for estimating the catchment population of a specific business location. + +- **Census Data**: (US Census / Eurostat) Admin-level statistics on income, age, and household size to score "Market Viability". + +### Firmographics & Business Intel + +- **OpenCorporates**: Verify legal entity status and official registration dates. + +- **LinkedIn Organization API**: Enrich with employee count, industry tags, and recent growth signals. +- **Clearbit / Apollo.io**: Deep profile matching to find technographics (what software they use) and key decision-maker contacts. + +### Environmental & Infrastructure + +- **OpenStreetMap (OSM)**: Calculate "Footfall Potential" by analyzing proximity to transit hubs, parking, and density of other retail POIs. + +- **WalkScore / TransitScore**: Rate the accessibility of consumer-facing businesses. + +### Industry Specifics + +- **TripAdvisor / Yelp**: Cross-reference hospitality ratings to find discrepancies or opportunities (e.g., highly rated on Google, poorly rated on Yelp). + +- **Plastics Industry Databases**: (Specific to Polymech) Cross-referencing registered recyclers lists provided by regional environmental agencies. + + diff --git a/packages/ui/docs/mcp.md b/packages/ui/docs/mcp.md new file mode 100644 index 00000000..3d2f5108 --- /dev/null +++ b/packages/ui/docs/mcp.md @@ -0,0 +1,495 @@ +# PoolyPress MCP Server + +> Model Context Protocol (MCP) server that lets any LLM search, browse, and read content on a PoolyPress instance. + +--- + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Client (Claude, etc.) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ POST /api/mcp + โ”‚ JSON-RPC 2.0 +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ McpProduct (Hono handler) โ”‚ +โ”‚ handlers.ts โ”‚ +โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ +โ”‚ initialize ยท tools/list โ”‚ +โ”‚ tools/call โ†’ tools.ts โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ direct function calls +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server-side logic โ”‚ +โ”‚ searchDirect ยท categories โ”‚ +โ”‚ pages-data ยท site-scrape โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Key decisions + +| Decision | Choice | Rationale | +|---|---|---| +| **SDK** | None โ€” raw JSON-RPC 2.0 | Zero deps; MCP spec is just JSON-RPC over HTTP | +| **Transport** | `POST /api/mcp` (HTTP) | Single endpoint; works with any HTTP client | +| **Auth** | `Bearer ` | Reuses existing `getUserCached()` โ€” no new auth layer | +| **Code reuse** | Direct imports from `products/serving/` | No REST-over-HTTP round-trips; zero duplication | + +--- + +## Source Files + +``` +server/src/products/mcp/ +โ”œโ”€โ”€ index.ts # McpProduct class (extends AbstractProduct) +โ”œโ”€โ”€ routes.ts # POST /api/mcp route definition +โ”œโ”€โ”€ handlers.ts # JSON-RPC 2.0 dispatcher +โ”œโ”€โ”€ tools.ts # 17 tool definitions + handlers +โ””โ”€โ”€ __tests__/ + โ””โ”€โ”€ mcp.e2e.test.ts # E2E tests +``` + +| File | Source | Purpose | +|---|---|---| +| [index.ts](../server/src/products/mcp/index.ts) | Product entry | Registers with platform product system | +| [routes.ts](../server/src/products/mcp/routes.ts) | Route | `POST /api/mcp` โ€” private (auth required) | +| [handlers.ts](../server/src/products/mcp/handlers.ts) | Handler | Dispatches `initialize`, `tools/list`, `tools/call` | +| [tools.ts](../server/src/products/mcp/tools.ts) | Tools | All tool schemas + handler functions | +| [registry.ts](../server/src/products/registry.ts) | Registration | `'mcp': McpProduct` entry | +| [products.json](../server/config/products.json) | Config | `mcp` enabled, depends on `serving` | + +### Upstream dependencies + +| Import | Source file | Used by | +|---|---|---| +| `searchDirect()` | [db-search.ts](../server/src/products/serving/db/db-search.ts) | `search_content`, `find_pages`, `find_pictures`, `find_files` | +| `fetchCategoriesServer()` | [db-categories.ts](../server/src/products/serving/db/db-categories.ts) | `list_categories` | +| `getCategoryState()` | [db-categories.ts](../server/src/products/serving/db/db-categories.ts) | `find_by_category` | +| `filterVisibleCategories()` | [db-categories.ts](../server/src/products/serving/db/db-categories.ts) | `list_categories` | +| `getPagesState()` | [pages-data.ts](../server/src/products/serving/pages/pages-data.ts) | `get_page_content`, `find_by_category` | +| `enrichPageData()` | [pages-data.ts](../server/src/products/serving/pages/pages-data.ts) | `get_page_content` | +| `JSDOM` + `Readability` | [jsdom](https://www.npmjs.com/package/jsdom), [@mozilla/readability](https://www.npmjs.com/package/@mozilla/readability) | `markdown_scraper` | +| `getPageTranslations()` | [pages-i18n.ts](../server/src/products/serving/pages/pages-i18n.ts) | `get_page_translations`, `set_page_translations` | +| `getTranslationGaps()` | [db-i18n.ts](../server/src/products/serving/db/db-i18n.ts) | `get_translation_gaps` | + +--- + +## Tools + +### `search_content` + +Full-text search across pages, posts, pictures, and VFS files. + +```jsonc +{ + "name": "search_content", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query (full-text)" }, + "limit": { "type": "number", "description": "Max results (default 20, max 50)" }, + "type": { "type": "string", "enum": ["all","pages","posts","pictures","files"] } + }, + "required": ["query"] + } +} +``` + +**Returns:** `[{ id, title, description, type, rank, url, created_at }]` + +**Backend:** `searchDirect({ q, limit, type, userId })` + +--- + +### `find_pages` + +Search specifically for pages. + +```jsonc +{ "name": "find_pages", "inputSchema": { "properties": { "query": {}, "limit": {} }, "required": ["query"] } } +``` + +**Returns:** `[{ id, title, slug, description, rank, created_at }]` + +--- + +### `find_pictures` + +Search specifically for pictures/images. + +```jsonc +{ "name": "find_pictures", "inputSchema": { "properties": { "query": {}, "limit": {} }, "required": ["query"] } } +``` + +**Returns:** `[{ id, title, description, image_url, rank, created_at }]` + +--- + +### `find_files` + +Search for files and folders in the Virtual File System (VFS). + +```jsonc +{ "name": "find_files", "inputSchema": { "properties": { "query": {}, "limit": {} }, "required": ["query"] } } +``` + +**Returns:** `[{ id, title, path, type, url, created_at }]` + +--- + +### `get_page_content` + +Get the full content of a specific page by slug or ID. + +```jsonc +{ + "name": "get_page_content", + "inputSchema": { + "type": "object", + "properties": { + "slug": { "type": "string", "description": "Page slug (e.g. \"about-us\")" }, + "id": { "type": "string", "description": "Page UUID (alternative to slug)" } + } + } +} +``` + +**Returns:** `{ id, title, slug, description, content, tags, is_public, created_at, updated_at, meta }` + +--- + +### `list_categories` + +List all content categories with hierarchy. + +```jsonc +{ + "name": "list_categories", + "inputSchema": { + "type": "object", + "properties": { + "parentSlug": { "type": "string", "description": "Filter children of parent" }, + "includeChildren": { "type": "boolean", "description": "Include nested children (default true)" } + } + } +} +``` + +**Returns:** `[{ id, name, slug, description, children: [{ id, name, slug }] }]` + +--- + +### `find_by_category` + +Get all pages belonging to a category (and descendants). + +```jsonc +{ + "name": "find_by_category", + "inputSchema": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "limit": { "type": "number", "description": "Max items (default 50)" }, + "includeDescendants": { "type": "boolean", "description": "Include child categories (default true)" } + }, + "required": ["slug"] + } +} +``` + +**Returns:** `{ category: { id, name, slug, description }, total, items: [{ id, title, slug, description, variables, created_at }] }` + +--- + +### `markdown_scraper` + +Scrape a URL and return clean Markdown. + +```jsonc +{ + "name": "markdown_scraper", + "inputSchema": { + "type": "object", + "properties": { + "url": { "type": "string", "description": "URL to scrape" } + }, + "required": ["url"] + } +} +``` + +**Returns:** `{ markdown, title }` or `{ error }` + +> Uses lightweight `fetch` + `Readability` + `Turndown`. For JavaScript-heavy pages, the full Scrapeless-powered endpoint at `POST /api/scrape/markdown` ([site-scrape.ts](../server/src/products/serving/site-scrape.ts)) is available separately. + +--- + +### `get_page_translations` + +Get existing translations for a page. Returns all widget translations and meta (title/description) for a specific target language. + +```jsonc +{ + "name": "get_page_translations", + "inputSchema": { + "type": "object", + "properties": { + "slug": { "type": "string", "description": "Page slug" }, + "id": { "type": "string", "description": "Page UUID (alternative to slug)" }, + "target_lang": { "type": "string", "description": "Target language code (e.g. \"es\", \"de\")" }, + "source_lang": { "type": "string", "description": "Source language code (default \"en\")" } + }, + "required": ["target_lang"] + } +} +``` + +**Returns:** `{ page_id, page_title, slug, target_lang, source_lang, translations: [{ widget_id, prop_path, source_text, translated_text, status, outdated }], summary: { total, translated, missing, outdated } }` + +--- + +### `set_page_translations` + +Save translations for a page. Batch-upserts widget translations for a target language. The LLM performs the translation โ€” this tool persists the results. + +```jsonc +{ + "name": "set_page_translations", + "inputSchema": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "id": { "type": "string" }, + "target_lang": { "type": "string" }, + "source_lang": { "type": "string", "description": "default \"en\"" }, + "translations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "widget_id": { "type": "string", "description": "Widget instance ID or \"__meta__\"" }, + "translated_text": { "type": "string" }, + "prop_path": { "type": "string", "description": "default \"content\"" }, + "status": { "type": "string", "enum": ["draft","machine","reviewed","published"] } + }, + "required": ["widget_id", "translated_text"] + } + } + }, + "required": ["target_lang", "translations"] + } +} +``` + +**Returns:** `{ success, page_id, slug, target_lang, count, message }` + +**Auth:** Owner only + +--- + +### `get_translation_gaps` + +Find pages/entities with missing or outdated translations for a given language. + +```jsonc +{ + "name": "get_translation_gaps", + "inputSchema": { + "type": "object", + "properties": { + "target_lang": { "type": "string", "description": "Target language code (e.g. \"de\")" }, + "entity_type": { "type": "string", "enum": ["page","category","type"], "description": "default \"page\"" }, + "mode": { "type": "string", "enum": ["missing","outdated","all"], "description": "default \"all\"" }, + "source_lang": { "type": "string", "description": "default \"en\"" } + }, + "required": ["target_lang"] + } +} +``` + +**Returns:** Array of entities with their untranslated/outdated source text + +--- + +## Protocol + +The endpoint speaks **JSON-RPC 2.0** โ€” no MCP SDK required on either side. + +### Methods + +| Method | Purpose | +|---|---| +| `initialize` | Handshake โ€” returns server info and capabilities | +| `tools/list` | Lists all 17 tools with schemas | +| `tools/call` | Execute a tool by name with arguments | + +### Request format + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "search_content", + "arguments": { "query": "plastic", "limit": 5 } + } +} +``` + +### Response format + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{ + "type": "text", + "text": "[{\"id\":\"...\",\"title\":\"...\"}]" + }] + } +} +``` + +### Error codes + +| Code | Meaning | +|---|---| +| `-32700` | Parse error (malformed JSON) | +| `-32600` | Invalid request (missing jsonrpc/method) | +| `-32601` | Method/tool not found | +| `-32603` | Internal error | + +--- + +## Usage + +### curl + +```bash +# Initialize +curl -X POST http://localhost:3001/api/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' + +# List tools +curl -X POST http://localhost:3001/api/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + +# Search +curl -X POST http://localhost:3001/api/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_content","arguments":{"query":"plastic","limit":3}}}' +``` + +### Claude Desktop / Cursor / Windsurf + +These clients expect an stdio transport or an SSE endpoint. To use the HTTP endpoint, you can wrap it with a thin stdio โ†” HTTP bridge: + +```json +{ + "mcpServers": { + "poolypress": { + "url": "http://localhost:3001/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + } +} +``` + +> **Note:** Claude Desktop 2025+ supports HTTP MCP servers natively via the `url` field. + +--- + +## Configuration + +The MCP product is enabled in [`server/config/products.json`](../server/config/products.json): + +```json +{ + "name": "mcp", + "enabled": true, + "workers": 0, + "deps": ["serving"] +} +``` + +To disable the MCP endpoint, set `"enabled": false`. + +--- + +## Testing + +```bash +cd server +npm run test:mcp +``` + +Runs 13 E2E tests covering all tools, error handling, and protocol compliance. + +Test file: [`server/src/products/mcp/__tests__/mcp.e2e.test.ts`](../server/src/products/mcp/__tests__/mcp.e2e.test.ts) + +--- + +## How to Add a New Tool + +1. **Define the tool** in [`tools.ts`](../server/src/products/mcp/tools.ts): + +```typescript +const myNewTool: McpTool = { + name: 'my_tool', + description: 'What this tool does โ€” shown to the LLM.', + inputSchema: { + type: 'object', + properties: { + param1: { type: 'string', description: '...' } + }, + required: ['param1'] + }, + handler: async (args, userId) => { + // Call server-side logic directly + const result = await someServerFunction(args.param1); + return result; + } +}; +``` + +2. **Register it** โ€” add to the `MCP_TOOLS` array at the bottom of `tools.ts`: + +```typescript +export const MCP_TOOLS: McpTool[] = [ + // โ€ฆ existing tools โ€ฆ + myNewTool +]; +``` + +That's it. The handler in `handlers.ts` auto-discovers tools via the `MCP_TOOLS_MAP`. No route changes needed. + +3. **Add tests** โ€” add a test case in [`mcp.e2e.test.ts`](../server/src/products/mcp/__tests__/mcp.e2e.test.ts) and update the tool count assertion. + +### Tool design guidelines + +- **Call server-side functions directly** โ€” never make HTTP requests to your own server +- **Accept `userId`** as second argument โ€” pass it through for visibility/ACL filtering +- **Return structured data** โ€” the handler serializes it to JSON automatically +- **Use existing caches** โ€” `getPagesState()`, `getCategoryState()`, etc. are all cached +- **Keep schemas minimal** โ€” LLMs work better with fewer, well-described parameters + +--- + +## Security + +- **Auth gating**: Every tool call resolves the user from the Bearer token. Anonymous requests get limited visibility (public content only). +- **VFS ACL**: File searches respect the existing ACL layer. +- **Visibility filtering**: `searchDirect()` applies owner/public/private filtering based on `userId`. +- **Rate limiting**: Inherits the platform's `apiRateLimiter` middleware. +- **Write operations**: Content creation, editing, and translation tools require authentication and verify page ownership (`userId === page.owner`). Admin-only actions are **not** available. diff --git a/packages/ui/docs/mux-integration.md b/packages/ui/docs/mux-integration.md deleted file mode 100644 index e471f025..00000000 --- a/packages/ui/docs/mux-integration.md +++ /dev/null @@ -1,306 +0,0 @@ -# Mux Video Integration - -This project integrates [Mux](https://www.mux.com) for professional video upload, processing, and streaming capabilities. - -## Overview - -Mux provides: -- **Video Upload**: Drag & drop or click to upload video files -- **Automatic Processing**: Videos are automatically transcoded and optimized -- **HLS Streaming**: Adaptive bitrate streaming for smooth playback -- **Thumbnail Generation**: Automatic thumbnails and poster images -- **Analytics**: Track video views and engagement (optional) - -## Architecture - -### Flow - -1. **Client requests upload URL** โ†’ Frontend calls our Supabase Edge Function -2. **Edge Function creates upload** โ†’ Calls Mux API to generate signed upload URL -3. **User uploads video** โ†’ Mux Uploader handles the upload with progress tracking -4. **Mux processes video** โ†’ Transcodes video, creates HLS stream, generates thumbnails -5. **Get playback ID** โ†’ Poll for asset creation, retrieve playback ID -6. **Play video** โ†’ Use Vidstack player with Mux HLS stream URL - -### Components - -- **MuxUploader**: React component for uploading videos (`@mux/mux-uploader-react`) -- **VideoCard**: Component for displaying videos with Vidstack player -- **mux-proxy**: Supabase Edge Function that interfaces with Mux API - -## Setup - -### 1. Get Mux Credentials - -1. Sign up at [mux.com](https://www.mux.com) -2. Navigate to **Settings** โ†’ **Access Tokens** -3. Create a new access token with permissions: - - `Mux Video` - Read and Write -4. Copy the **Token ID** and **Token Secret** - -### 2. Configure Environment Variables - -Add these to your Supabase Edge Function environment variables: - -```bash -MUX_TOKEN_ID=your_token_id_here -MUX_TOKEN_SECRET=your_token_secret_here -``` - -To set them in Supabase: - -```bash -# Using Supabase CLI -supabase secrets set MUX_TOKEN_ID=your_token_id -supabase secrets set MUX_TOKEN_SECRET=your_token_secret - -# Or via Supabase Dashboard -# Project Settings โ†’ Edge Functions โ†’ Secrets -``` - -### 3. Deploy Edge Function - -```bash -supabase functions deploy mux-proxy -``` - -## Usage - -### Upload Video - -```tsx -import MuxUploader from "@mux/mux-uploader-react"; -import { supabase } from "@/integrations/supabase/client"; - -const fetchUploadUrl = async () => { - const response = await fetch( - `${supabase.supabaseUrl}/functions/v1/mux-proxy`, - { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ action: 'create-upload' }), - } - ); - - const { data } = await response.json(); - return data.url; -}; - -function VideoUpload() { - return ( - { - console.log('Upload complete!', event.detail); - }} - /> - ); -} -``` - -### Play Video - -Once you have the playback ID from Mux, you can play the video: - -```tsx -import VideoCard from "@/components/VideoCard"; - -function VideoPlayer({ playbackId }: { playbackId: string }) { - const videoUrl = `https://stream.mux.com/${playbackId}.m3u8`; - const thumbnailUrl = `https://image.mux.com/${playbackId}/thumbnail.jpg`; - - return ( - - ); -} -``` - -## Mux API Actions - -### create-upload - -Creates a new direct upload URL. - -**Request:** -```json -{ - "action": "create-upload" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "id": "upload_abc123", - "url": "https://storage.googleapis.com/...", - "status": "waiting" - } -} -``` - -### get-upload - -Get the status of an upload and check if asset was created. - -**Request:** -```json -{ - "action": "get-upload", - "uploadId": "upload_abc123" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "id": "upload_abc123", - "status": "asset_created", - "asset_id": "asset_xyz789" - } -} -``` - -### get-asset - -Get asset details including playback IDs. - -**Request:** -```json -{ - "action": "get-asset", - "assetId": "asset_xyz789" -} -``` - -**Response:** -```json -{ - "success": true, - "data": { - "id": "asset_xyz789", - "status": "ready", - "playback_ids": [ - { - "id": "playback_def456", - "policy": "public" - } - ] - } -} -``` - -## Database Schema - -Store Mux video data in your `videos` table: - -```sql -CREATE TABLE videos ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL, - title TEXT NOT NULL, - description TEXT, - video_url TEXT NOT NULL, -- https://stream.mux.com/{playback_id}.m3u8 - thumbnail_url TEXT, -- https://image.mux.com/{playback_id}/thumbnail.jpg - meta JSONB, -- { mux_asset_id, mux_playback_id } - created_at TIMESTAMP DEFAULT NOW() -); -``` - -Store in meta: -- `mux_asset_id`: For managing the asset via Mux API -- `mux_playback_id`: For generating stream/thumbnail URLs - -## Mux URLs - -### Stream URL (HLS) -``` -https://stream.mux.com/{PLAYBACK_ID}.m3u8 -``` - -This is an HLS stream that works with Vidstack, Mux Player, and most video players. - -### Thumbnail URL -``` -https://image.mux.com/{PLAYBACK_ID}/thumbnail.jpg -``` - -Query parameters: -- `?width=1280` - Set width -- `?height=720` - Set height -- `?time=10` - Thumbnail at 10 seconds - -### MP4 URL (if enabled) -``` -https://stream.mux.com/{PLAYBACK_ID}/high.mp4 -``` - -Available qualities: `low.mp4`, `medium.mp4`, `high.mp4` - -## Webhooks (Optional) - -For production, set up Mux webhooks to get notified when: -- Upload completes (`video.upload.asset_created`) -- Video is ready (`video.asset.ready`) -- Errors occur (`video.asset.errored`) - -This is more efficient than polling. See [Mux Webhooks Docs](https://docs.mux.com/guides/listen-for-webhooks). - -## Playground - -Test the integration at `/playground/video-player`: -- **Upload tab**: Upload videos using Mux -- **Test with URL tab**: Test Vidstack player with any video URL - -## Pricing - -Mux charges based on: -- **Encoding**: Minutes of video processed -- **Streaming**: Minutes of video delivered -- **Storage**: GB-months of video stored - -See [Mux Pricing](https://www.mux.com/pricing) for current rates. - -Free tier includes: -- $20/month in free credits -- Enough for ~40 minutes of encoding + 100 hours of streaming - -## Troubleshooting - -### Upload fails immediately -- Check that MUX_TOKEN_ID and MUX_TOKEN_SECRET are set in Supabase -- Verify the edge function is deployed -- Check browser console for CORS errors - -### Video stuck in "processing" -- Large videos can take several minutes to process -- Check Mux dashboard for asset status -- Verify the upload completed successfully - -### Video won't play -- Check that playback policy is set to "public" -- Verify the HLS URL format is correct -- Check browser console for player errors - -## Resources - -- [Mux Documentation](https://docs.mux.com) -- [Mux Uploader Docs](https://www.mux.com/docs/guides/mux-uploader) -- [Vidstack Player Docs](https://vidstack.io) -- [Mux Dashboard](https://dashboard.mux.com) - diff --git a/packages/ui/docs/nested-containers-edit.md b/packages/ui/docs/nested-containers-edit.md new file mode 100644 index 00000000..055f8a1a --- /dev/null +++ b/packages/ui/docs/nested-containers-edit.md @@ -0,0 +1,251 @@ +# Nested Containers โ€” Edit-Mode Architecture + +How widgets that embed their own canvas (`TabsWidget`, `LayoutContainerWidget`) participate in the editor's data flow. + +--- + +## Terminology + +| Term | Description | +|---|---| +| **Parent page** | The main page being edited. Layout stored in `loadedPages` under `page-`. | +| **Nested canvas** | A `GenericCanvas` rendered *inside* a widget, identified by a synthetic `pageId` (e.g., `tab-layout-1`, `nested-layout-xyz`). | +| **Widget props** | The JSON blob stored on the parent page's `WidgetInstance.props` โ€” includes embedded `layoutData`. | +| **`loadedPages`** | In-memory `Map` managed by `LayoutContext`. Holds layouts for both the parent page and every active nested canvas. | + +--- + +## Data Flow Overview + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ UserPageEdit โ”‚ +โ”‚ page- in loadedPages โ”‚ +โ”‚ โ””โ”€ containers[] โ†’ widgets[] โ†’ TabsWidget (widgetId: "w1") โ”‚ +โ”‚ props.tabs[0].layoutData โ† embedded PageLayout โ”‚ +โ”‚ props.tabs[0].layoutId = "tab-layout-1" โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ GenericCanvas receives initialLayout = tabs[0].layoutData +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ GenericCanvasEdit (pageId = "tab-layout-1") โ”‚ +โ”‚ Hydration: loadedPages.set("tab-layout-1", initialLayout) โ”‚ +โ”‚ User edits โ†’ Commands against "tab-layout-1" in loadedPages โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ Sync effect writes back +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ TabsWidget sync effect โ”‚ +โ”‚ Reads loadedPages.get("tab-layout-1") โ”‚ +โ”‚ Calls onPropsChange({ tabs: [...updated layoutData...] }) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ handlePropsChange in LayoutContainerEdit +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ updateWidgetProps(parentPageId, "w1", { tabs: [...] }) โ”‚ +โ”‚ โ†’ UpdateWidgetSettingsCommand against parent page โ”‚ +โ”‚ โ†’ parent page layout in loadedPages updated โ”‚ +โ”‚ โ†’ HistoryManager tracks command (undo/redo) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## 1. Hydration โ€” Loading the Nested Layout + +**File:** [`GenericCanvasEdit.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/GenericCanvasEdit.tsx#L72-L89) + +When a nested widget mounts in edit mode, `GenericCanvasEdit` receives `initialLayout` (the embedded `layoutData` from the widget's props) and a synthetic `pageId`. + +```ts +useEffect(() => { + // First visit: copy prop into loadedPages + if (initialLayout && !layout) { + hydratePageLayout(pageId, initialLayout); + return; + } + // Staleness check: re-hydrate if prop is newer + if (initialLayout && layout + && initialLayout.updatedAt > layout.updatedAt) { + hydratePageLayout(pageId, initialLayout); + return; + } + // Fallback: load from API (not used for embedded layouts) + if (!layout) { + loadPageLayout(pageId, pageName); + } +}, [pageId, pageName, layout, loadPageLayout, hydratePageLayout, initialLayout]); +``` + +**Key points:** +- `hydratePageLayout` is a simple `setLoadedPages(prev => new Map(prev).set(pageId, layout))` โ€” no API call. +- The staleness check (comparing `updatedAt`) ensures that if the parent page is reloaded or the prop changes externally, the cached layout is replaced. + +--- + +## 2. Editing โ€” Commands Against the Nested Layout + +All editor operations (add widget, move, remove, update settings) go through `LayoutContext` functions like `addWidgetToPage`, `updateWidgetProps`, etc. Each creates a `Command` (see [`commands.ts`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/commands.ts)) and executes it via `HistoryManager`. + +Commands operate on `loadedPages` by `pageId`. For a nested canvas, the `pageId` is the synthetic ID (e.g., `tab-layout-1`), so commands target the nested layout directly: + +```ts +// Adding a widget to a tab canvas: +addWidgetToPage("tab-layout-1", containerId, widgetInstance) +// โ†’ AddWidgetCommand reads loadedPages.get("tab-layout-1") +// โ†’ Modifies it, calls context.updateLayout("tab-layout-1", newLayout) +``` + +**This is fully tracked by undo/redo** โ€” `HistoryManager` stores the command in its `past` stack. + +--- + +## 3. Sync-Back โ€” Writing Nested Changes to Parent Props + +The nested layout edits live in `loadedPages["tab-layout-1"]`, but the parent page's save function only persists `loadedPages["page-"]`. So the nested data must be **synced back** as widget props on the parent. + +### TabsWidget + +**File:** [`TabsWidget.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/components/widgets/TabsWidget.tsx#L100-L122) + +```ts +useEffect(() => { + if (!isEditMode) return; + let changed = false; + const newTabs = tabs.map(t => { + const layout = loadedPages.get(t.layoutId); + if (layout) { + const propTimestamp = t.layoutData?.updatedAt || 0; + if (layout.updatedAt > propTimestamp) { + const layoutChanged = + JSON.stringify(layout) !== JSON.stringify(t.layoutData); + if (layoutChanged) { + changed = true; + return { ...t, layoutData: layout }; + } + } + } + return t; + }); + if (changed) { + onPropsChange({ tabs: newTabs }); + } +}, [loadedPages, isEditMode, onPropsChange, tabs]); +``` + +**Important:** Iterates **all** tabs, not just the current one. This ensures that if you edit Tab A then switch to Tab B, Tab A's changes are still written back before save. + +### LayoutContainerWidget + +**File:** [`LayoutContainerWidget.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/components/widgets/LayoutContainerWidget.tsx#L56-L70) + +Same pattern but simpler โ€” only one nested layout: + +```ts +useEffect(() => { + if (uniqueNestedPageId && isEditMode) { + const currentLayout = loadedPages.get(uniqueNestedPageId); + if (currentLayout) { + const propTimestamp = nestedLayoutData?.updatedAt || 0; + if (currentLayout.updatedAt > propTimestamp) { + const layoutChanged = + JSON.stringify(currentLayout) !== JSON.stringify(nestedLayoutData); + if (layoutChanged) { + onPropsChange({ nestedLayoutData: currentLayout }); + } + } + } + } +}, [uniqueNestedPageId, loadedPages, isEditMode, onPropsChange, nestedLayoutData]); +``` + +### onPropsChange โ†’ updateWidgetProps โ†’ Command + +**File:** [`LayoutContainerEdit.tsx`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/LayoutContainerEdit.tsx#L621-L627) + +When `onPropsChange` is called, it flows to: + +```ts +const handlePropsChange = async (newProps) => { + await updateWidgetProps(pageId, widget.id, newProps); +}; +``` + +`updateWidgetProps` creates an [`UpdateWidgetSettingsCommand`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/commands.ts#L184-L261) against the **parent page's** `pageId`. This: +- Snapshots the old `widget.props` for undo +- Merges `newProps` into `widget.props` +- Updates `loadedPages[parentPageId]` +- Pushes to `HistoryManager.past` (undoable) + +--- + +## 4. Saving โ€” Persistence Path + +**File:** [`usePageEditHandlers.ts`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/pages/editor/hooks/usePageEditHandlers.ts#L187-L226) + +```ts +const handleSave = async () => { + loadedPages.forEach((layout, id) => { + if (id.startsWith('page-')) { + // Save as page content + promises.push(updatePage(pId, { content: rootContent })); + } else if (id.startsWith('layout-')) { + // Save as standalone layout + promises.push(updateLayout(layoutId, { layout_json: layout })); + } + // IDs like "tab-layout-1" or "nested-layout-xyz" + // are INTENTIONALLY SKIPPED โ€” their data lives inside + // the parent page's widget props (already synced by step 3) + }); +}; +``` + +**The nested layouts are NOT saved independently.** They are saved **embedded** within the parent page's widget props. This is correct because: + +1. Sync effect (step 3) copies `loadedPages["tab-layout-1"]` โ†’ `widget.props.tabs[0].layoutData` +2. This updates `loadedPages["page-"]` via `UpdateWidgetSettingsCommand` +3. `handleSave` writes `loadedPages["page-"]` to the API +4. On reload, `widget.props.tabs[0].layoutData` is passed as `initialLayout` + +--- + +## 5. Undo/Redo + +All operations produce `Command` objects tracked by [`HistoryManager`](file:///c:/Users/zx/Desktop/polymech/pm-pics/src/modules/layout/HistoryManager.ts): + +| Action | Command | Target `pageId` | +|---|---|---| +| Add widget to tab | `AddWidgetCommand` | `tab-layout-1` | +| Move widget in tab | `MoveWidgetCommand` | `tab-layout-1` | +| Sync tab data to parent | `UpdateWidgetSettingsCommand` | `page-` | + +**Undo** pops the last command from `past` and calls `command.undo(context)`. Because syncs also produce commands, undoing a sync restores the previous `tabs` array on the parent. + +> **Note:** An undo of the sync command reverts the tab data at the *parent* level but doesn't automatically revert the *nested* layout in `loadedPages["tab-layout-1"]`. These are two separate entries. In practice, the user undoes the inner edit (which reverts the nested layout), and the next sync cycle propagates that reversion to the parent. + +--- + +## 6. ID Conventions + +| Prefix | Source | Saved by | +|---|---|---| +| `page-` | Real page from database | `updatePage()` API | +| `layout-` | Standalone reusable layout | `updateLayout()` API | +| `tab-layout-` | TabsWidget tab | Embedded in parent page's widget props | +| `nested-layout-` | LayoutContainerWidget | Embedded in parent page's widget props | + +--- + +## 7. Known Pitfalls + +### Sync must cover all tabs +The sync effect must iterate **all** tabs, not just the active tab. Otherwise, switching tabs before saving drops the inactive tab's edits. (Fixed March 2026.) + +### Re-hydration staleness +When `GenericCanvasEdit` detects that `initialLayout.updatedAt > layout.updatedAt`, it re-hydrates from the prop. Without this check, a reloaded page (with updated server data) would render stale cached data from a previous session. + +### JSON.stringify comparison cost +Both `TabsWidget` and `LayoutContainerWidget` use `JSON.stringify` comparison as a safety check before calling `onPropsChange`. For very large layouts this could be slow โ€” but it prevents infinite re-render loops where the timestamp guard alone isn't sufficient (e.g., when timestamps match but content differs due to manual edits). + +### Nested nesting +`TabsWidget` and `LayoutContainerWidget` can be nested inside each other. Each level adds another entry to `loadedPages` and another sync effect. The same sync/hydration pattern applies recursively โ€” each widget syncs its own nested layout back to its parent's props. diff --git a/packages/ui/docs/nested-ex.md b/packages/ui/docs/nested-ex.md new file mode 100644 index 00000000..935b2cd0 --- /dev/null +++ b/packages/ui/docs/nested-ex.md @@ -0,0 +1,155 @@ +# Nested Layout Execution โ€” Race Conditions & Solutions + +## Problem + +Widgets stored inside nested layouts (e.g. `TabsWidget` tabs) are not displayed in edit mode, despite working in view mode. The root cause is a **race condition** between layout hydration and the SYNC-BACK effect. + +## Architecture Overview + +``` +UserPage โ†’ LayoutProvider (single instance) + โ””โ”€โ”€ GenericCanvas (main page) + โ””โ”€โ”€ GenericCanvasEdit + โ””โ”€โ”€ LayoutContainerEdit + โ””โ”€โ”€ TabsWidget (widget with nested layouts) + โ””โ”€โ”€ GenericCanvas (per-tab sub-layout) + โ””โ”€โ”€ GenericCanvasEdit (child) +``` + +### Key files + +| File | Role | +|------|------| +| [LayoutContext.tsx](../src/modules/layout/LayoutContext.tsx) | Shared `loadedPages` state, `hydratePageLayout`, `loadPageLayout` | +| [GenericCanvas.tsx](../src/modules/layout/GenericCanvas.tsx) | Suspense wrapper โ€” lazy-loads Edit, falls back to View | +| [GenericCanvasEdit.tsx](../src/modules/layout/GenericCanvasEdit.tsx) | Edit mode canvas โ€” hydration useEffect (lines 73-100) | +| [GenericCanvasView.tsx](../src/modules/layout/GenericCanvasView.tsx) | View mode canvas โ€” also hydrates from `initialLayout` | +| [TabsWidget.tsx](../src/components/widgets/TabsWidget.tsx) | Nested layout host โ€” SYNC-BACK effect (lines 100-139) | +| [LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | `getPageLayout` โ€” creates empty defaults, `loadRootData` prefix matching | +| [LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) | Renders widgets with `{...widget.props}` spread (line 776) | + +## Race Condition Sequence + +``` +1. Main page hydrates โ†’ TabsWidget mounts with stored `tabs[].layoutData` +2. TabsWidget renders per tab +3. GenericCanvasEdit mounts โ€” layout=undefined, initialLayout=stored data +4. BEFORE useEffect hydration runs: + โ”œโ”€โ”€ Suspense fallback (GenericCanvasView) may call loadPageLayout() + โ”‚ โ””โ”€โ”€ loadRootData("tab-layout-1") โ†’ prefix mismatch โ†’ empty default + โ””โ”€โ”€ OR GenericCanvasEdit itself calls loadPageLayout() on wrong branch +5. Empty layout enters loadedPages with fresh Date.now() timestamp +6. SYNC-BACK fires โ†’ empty layout is "newer" โ†’ overwrites stored layoutData +7. Stored widgets are permanently lost for this session +``` + +### Prefix mismatch detail + +[LayoutManager.ts](../src/modules/layout/LayoutManager.ts) `loadRootData` (line 153-154): + +```ts +const isPage = pageId.startsWith('page-'); +const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-'); +``` + +`tab-layout-*` starts with `tab-` (not `tabs-`), so neither branch matches โ†’ returns empty default. + +## Current Fix (band-aid) + +[TabsWidget.tsx](../src/components/widgets/TabsWidget.tsx) lines 108-116: + +SYNC-BACK compares live vs stored widget counts. If live=0 and stored>0, skips the sync and re-hydrates from stored data. This is a **heuristic** guard โ€” it doesn't generalize to all edge cases (e.g. legitimately empty nested layouts that later get widgets added). + +## Proposed Solutions + +### Solution 1: `hydratedIds` set in LayoutContext โญ recommended + +Track which layout IDs have been authoritatively populated vs created as empty defaults. + +**In [LayoutContext.tsx](../src/modules/layout/LayoutContext.tsx):** + +```ts +// Mutable set โ€” no reactivity needed, checked synchronously during SYNC-BACK +const [hydratedIds] = useState(() => new Set()); + +const hydratePageLayout = useCallback((pageId: string, layout: PageLayout) => { + hydratedIds.add(pageId); // mark as authoritatively hydrated + setLoadedPages(prev => new Map(prev).set(pageId, layout)); + setIsLoading(false); +}, []); +``` + +Expose `hydratedIds` (or an `isHydrated(id)` helper) via context. + +**In [TabsWidget.tsx](../src/components/widgets/TabsWidget.tsx) (and any future nested layout widget):** + +```ts +const layout = loadedPages.get(t.layoutId); +if (layout && !isHydrated(t.layoutId) && t.layoutData) { + // Not yet authoritatively populated โ€” skip SYNC-BACK + return t; +} +``` + +**State machine per layout ID:** + +``` +UNKNOWN โ”€โ”€โ–บ HYDRATING โ”€โ”€โ–บ READY + โ”‚ โ–ฒ + โ””โ”€โ”€ (no initialLayout) โ”€โ”€โ”˜ (loadPageLayout = also READY) +``` + +**Edge cases handled:** + +| Case | `initialLayout` | `hydratedIds` | SYNC-BACK | +|------|-----------------|---------------|-----------| +| Stored tab with widgets | โœ… present | set on hydrate | trusts after hydrate | +| New empty tab (user just created) | โŒ undefined | set on loadPageLayout | trusts empty layout โœ… | +| Tab inside tab (deep nesting) | โœ… per level | each level independent | each SYNC-BACK checks own children | +| Implicit container auto-creation | n/a (mutation) | doesn't change flag | no effect on SYNC-BACK | + +--- + +### Solution 2: Gate `loadPageLayout` when `initialLayout` exists + +**In [GenericCanvasEdit.tsx](../src/modules/layout/GenericCanvasEdit.tsx) line 96-99:** + +```diff +-if (!layout) { ++if (!layout && !initialLayout) { + loadPageLayout(pageId, pageName); + } +``` + +**Same change in [GenericCanvasView.tsx](../src/modules/layout/GenericCanvasView.tsx) line 38-40.** + +This prevents the empty default from ever being created when `initialLayout` is provided. The canvas will stay in loading state until the hydration effect runs (next tick). + +**Pros:** 2-line fix, eliminates the root cause. +**Cons:** Doesn't protect against future patterns where a nested layout might lose its `initialLayout` prop during React reconciliation. + +--- + +### Solution 3: Fix the prefix mismatch + +**In [LayoutManager.ts](../src/modules/layout/LayoutManager.ts) line 154:** + +```diff +-const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-'); ++const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-') || pageId.startsWith('tab-'); +``` + +**Pros:** 1-line fix, prevents DB miss for tab sub-layouts. +**Cons:** Only addresses one symptom โ€” any nested layout with an unrecognized prefix would still hit the same problem. + +--- + +## Recommended Approach + +Apply **all three** in order of priority: + +1. **Solution 2** (gate `loadPageLayout`) โ€” eliminates the source of empty layouts, 2 lines +2. **Solution 1** (`hydratedIds`) โ€” semantic guard for SYNC-BACK, generalizes to any nesting depth +3. **Solution 3** (prefix fix) โ€” defense in depth, prevents DB misses + +Then **remove** the current widget-counting heuristic from TabsWidget, since `hydratedIds` makes it redundant. diff --git a/packages/ui/docs/optimize-video-bg.sh b/packages/ui/docs/optimize-video-bg.sh new file mode 100644 index 00000000..f8e9e5ff --- /dev/null +++ b/packages/ui/docs/optimize-video-bg.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Optimize a video for web background use +# Usage: ./optimize-video-bg.sh input.mp4 [output.mp4] + +INPUT="$1" +OUTPUT="${2:-${INPUT%.*}-web.mp4}" +POSTER="${OUTPUT%.*}-poster.jpg" + +if [ -z "$INPUT" ]; then + echo "Usage: $0 input.mp4 [output.mp4]" + exit 1 +fi + +echo "=== Optimizing video for web background ===" +echo "Input: $INPUT" +echo "Output: $OUTPUT" + +# 1. Encode to 720p, H.264, capped at 2 Mbps, 15s max, no audio +ffmpeg -y -i "$INPUT" \ + -vf "scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720" \ + -c:v libx264 \ + -preset slow \ + -crf 28 \ + -maxrate 2M \ + -bufsize 4M \ + -profile:v main \ + -level 3.1 \ + -pix_fmt yuv420p \ + -movflags +faststart \ + -an \ + -t 15 \ + "$OUTPUT" + +echo "" +echo "=== Extracting poster frame ===" + +# 2. Extract a poster image from the 1s mark +ffmpeg -y -i "$OUTPUT" \ + -ss 1 \ + -frames:v 1 \ + -q:v 2 \ + "$POSTER" + +echo "" +echo "=== Results ===" +echo "Video: $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" +echo "Poster: $POSTER ($(du -h "$POSTER" | cut -f1))" +echo "" +echo "HTML usage:" +echo "" diff --git a/packages/ui/docs/overview-todos.md b/packages/ui/docs/overview-todos.md deleted file mode 100644 index 038f20a6..00000000 --- a/packages/ui/docs/overview-todos.md +++ /dev/null @@ -1,53 +0,0 @@ - -# Master Implementation Plan - -This document serves as the central roadmap, referencing tasks from: -- [`database-todos.md`](./database-todos.md) (DB) -- [`security.md`](./security.md) (SEC) -- [`caching.md`](./caching.md) (CACHE) - -## Phase 1: Foundation (Schema & Data Security) -*Goal: Secure the data layer and enable collaboration primitives.* - -- [ ] **[DB] Split `profiles` into `profiles_public` & `user_secrets`** - - [ ] Create table & Migrate data (Ref: [`src/integrations/supabase/types.ts`](../src/integrations/supabase/types.ts)). - - [ ] **[SEC]** Apply RLS to `user_secrets` (`user_id = auth.uid()`). -- [ ] **[DB] Create `page_collaborators` Table** - - [ ] Define columns & Unique Constraints. - - [ ] **[SEC]** Implement RLS for shared Page access (Viewer/Editor logic). - -## Phase 2: Server Core & API -*Goal: Build the "Smart Proxy" layer to handle data fetching and caching.* - -- [ ] **[CACHE] Implement `CacheAdapter`** - - [ ] Create Interface (Target: `server/src/commons/cache/types.ts`). - - [ ] Implement `MemoryCache` (default) & `RedisCache` (optional). -- [ ] **[DB] Implement Server Endpoints in [`ServingProduct`](../server/src/products/serving/index.ts)** - - [ ] `GET /api/feed` (Hydrated View-Ready Feed). - - [ ] `GET /api/profile/:id` (Public Profile). - - [ ] `GET /api/me/secrets` (Secure Settings access). -- [ ] **[CACHE] Apply Caching to Endpoints** - - [ ] Cache Feed (60s) & Profiles (5m). - -## Phase 3: Client Security & Refactor -*Goal: Stop leaking keys and move to the Proxy.* - -- [ ] **[SEC] Critical: Remove Client-Side Key Fetching** - - [ ] Scrub `profiles` selects in [`Profile.tsx`](../src/pages/Profile.tsx) and [`db.ts`](../src/lib/db.ts). - - [ ] Remove API Key inputs from Profile UI in [`Profile.tsx`](../src/pages/Profile.tsx). -- [ ] **[DB] Client Data Layer Refactor** - - [ ] Update [`db.ts`](../src/lib/db.ts) to use `fetchFeedFromProxy` / `fetchProfileFromProxy`. - - [ ] Deprecate direct Supabase `select` calls for core content. -- [ ] **[SEC] Hardening** - - [ ] **[SEC]** Handle 404s/403s in [`Post.tsx`](../src/pages/Post.tsx) correctly. - -## Phase 4: Performance & Optimization -*Goal: Instant loads and "feels native" speed.* - -- [ ] **[DB] Server-Side Injection (SSR-Lite)** - - [ ] Inject `window.__INITIAL_STATE__` into `index.html` via [`ServingProduct`](../server/src/products/serving/index.ts). -- [ ] **[CACHE] Client Hydration** - - [ ] Configure React Query to hydrate from `__INITIAL_STATE__`. - - [ ] Set global `staleTime` to 5m. -- [ ] **[SEC] Rate Limiting** - - [ ] Add limits to API endpoints. diff --git a/packages/ui/docs/page-commands.md b/packages/ui/docs/page-commands.md deleted file mode 100644 index e8e07182..00000000 --- a/packages/ui/docs/page-commands.md +++ /dev/null @@ -1,87 +0,0 @@ -# Page Commands & Undo/Redo System Proposal - -## Overview -To support robust Undo/Redo functionality for the User Page Builder, we propose implementing the **Command Pattern**. Every modification to the page layout (add, remove, move, resize, update settings) will be encapsulated as a `Command` object. - -## Command Interface - -```typescript -interface Command { - id: string; - type: string; - timestamp: number; - execute(): Promise; - undo(): Promise; -} -``` - -## Command Stack -We will maintain two stacks in the `LayoutContext` or a new `HistoryContext`: -- `past: Command[]` -- `future: Command[]` - -## Proposed Commands - -### 1. AddWidgetCommand -- **Execute**: Adds a widget to a specific container/index. -- **Undo**: Removes the widget with the specific ID. - -### 2. RemoveWidgetCommand -- **Execute**: Removes a widget. Store the widget's state (props, ID, location) before removal. -- **Undo**: Restores the widget to its original container/index with preserved props. - -### 3. MoveWidgetCommand -- **Execute**: Moves widget from (Container A, Index X) to (Container B, Index Y). -- **Undo**: Moves widget back to (Container A, Index X). - -### 4. UpdateWidgetSettingsCommand -- **Execute**: Updates `widget.props` with new values. Store `previousProps`. -- **Undo**: Reverts `widget.props` to `previousProps`. - -### 5. AddContainerCommand / RemoveContainerCommand -- Similar logic to widgets but for layout containers. - -### 6. ResizeContainerCommand -- **Execute**: Updates container column sizes. -- **Undo**: Reverts to previous column sizes. - -## Implementation Strategy - -1. **Refactor `useLayout`**: Move direct state mutations into specific command classes or factory functions. -2. **Action Dispatcher**: Create a `dispatch(action)` function that: - - Creates the appropriate Command. - - Executes `command.execute()`. - - Pushes command to `past` stack. - - Clears `future` stack. -3. **Hotkeys**: Bind `Ctrl+Z` (Undo) and `Ctrl+Y / Ctrl+Shift+Z` (Redo). - -## Storage Boundaries & Persistence - -### 1. In-Memory Store (Primary) -- **Scope**: Current Browser Tab / Session. -- **Implementation**: React State or `useReducer` within `LayoutContext`. -- **Behavior**: fast, synchronous updates. Cleared on page reload or navigation. - -### 2. Browser Storage (localStorage) -- **Role**: Crash Recovery & Session Continuity. -- **Strategy**: - - Persist the `currentLayout` state to `localStorage` on every change (debounced). - - **Proposed**: Persist the `past` and `future` command stacks to `localStorage` as well. - - **Constraint**: All `Command` objects must be strictly JSON-serializable (no function references). - - **Key**: `page_editor_history_${pageId}`. -- **Benefit**: Users can refresh the page and still Undo their last action. - -### 3. Server State (Database) -- **Role**: Permanent Storage & Collaboration Source of Truth. -- **Interaction**: - - "Save" commits the current state to Supabase. - - **History Clearance**: Typically, saving *does not* clear the Undo history (allowing "Undo Save"), but navigating away does. - - **Dirty State**: If `past.length > lastSavedIndex`, the UI shows "Unsaved Changes". - -### 4. Boundary Enforcement -- **Serialization**: Commands must store *copies* of data (e.g., `previousProps`), not references to live objects. -- **Isolation**: Undo operations must not trigger side effects (like API calls) other than updating the local layout state, unless explicitly designed (e.g., re-uploading a deleted image is complex; usually we just restore the *reference* to the image URL). - -## Edge Cases -- **Multi-user editing**: Simple command history assumes single-player mode. Implementation complexity increases significantly with real-time collaboration (requiring OT or CRDTs). For now, we assume last-write-wins or locking. -- **Failed operations**: If `execute()` fails, the command stack should not update. diff --git a/packages/ui/docs/page-edit.md b/packages/ui/docs/page-edit.md deleted file mode 100644 index bd6162b9..00000000 --- a/packages/ui/docs/page-edit.md +++ /dev/null @@ -1,84 +0,0 @@ -# UserPageEdit Refactoring Plan - -The `UserPageEdit.tsx` component has grown too large (~900 lines) and handles too many responsibilities. This plan outlines the steps to decompose it into manageable, single-purpose components and custom hooks, leveraging the existing Action System for cleaner communication. - -## 1. Goal -Split `UserPageEdit.tsx` to improve maintainability, verify separation of concerns, and fully utilize the `src/actions/` system to decouple UI components (like the Ribbon) from the logic. - -## 2. Proposed Architecture - -### 2.1. Directories -`src/pages/editor/` - - `components/` - - `hooks/` - - `UserPageEdit.tsx` (The main entry point, simplified) - -### 2.2. Custom Hooks & Logic -Move state and logic into `src/pages/editor/hooks/`. Crucially, we will use `useActions` to register capabilities. - -1. **`usePageEditorState.ts`** - - Manages UI state: `isSidebarCollapsed`, `showHierarchy`, `showTypeFields`, `selectedWidgetId`. - - **Action Registration**: Registers actions like `View/ToggleSidebar`, `View/ToggleHierarchy`, `View/ToggleTypeFields`. - -2. **`usePageTemplates.ts`** - - Manages template state. - - **Action Registration**: Registers `File/LoadTemplate`, `File/SaveTemplate`. - -3. **`useEmailActions.ts`** - - Manages email state. - - **Action Registration**: Registers `Email/SendTest`, `Email/TogglePreview`. - -4. **`useEditorActions.ts` (Core Logic)** - - Wraps `useLayout` and `useLayouts` context methods. - - **Action Registration**: - - `Edit/Undo` - - `Edit/Redo` - - `File/Save` - - `File/ImportLayout` - - `File/ExportLayout` - - `Edit/AddContainer` - - `Edit/AddWidget` - - `Edit/DeletePage` - -### 2.3. Components (UI Extraction) -Move UI sections into `src/pages/editor/components/`: - -1. **`EditorSidebar.tsx`** - - Subscribes to UI state via context or props (managed by `UserPageEdit`). - - Handlers can trigger Actions. - - Renders `HierarchyTree`. - -2. **`EditorMainArea.tsx`** - - The central workspace. - - Renders `GenericCanvas`. - -3. **`EditorRightPanel.tsx`** - - The properties panel. - -4. **`PageRibbonBar.tsx` (Refactor)** - - **Change**: Instead of accepting 30+ props, it will use `useActions()` to retrieve registered actions (`Save`, `Undo`, `Redo`, `ToggleVisibility`, etc.) and bind them to buttons. - - Props will be minimized to just `page` (for context) and layout specific data. - -5. **`EditorDialogs.tsx`** - - A container component that renders all global dialogs (Email, Settings, Templates) based on state. - -## 3. Implementation Steps - -1. **Setup Directory**: Create `src/pages/editor/` structure. -2. **RefactorHooks**: - - Implement `useEditorActions` to register core actions. - - Implement `usePageEditorState` for UI toggles. -3. **Refactor `PageRibbonBar`**: - - Update it to use `useActions().getActionsByGroup('History')` etc. - - Remove prop drilling for `onUndo`, `onRedo`, `onSave`. -4. **Extract Components**: - - Move JSX to `EditorSidebar`, `EditorMainArea`, `EditorRightPanel`. -5. **Reassemble `UserPageEdit`**: - - Initialize hooks. - - Render `ActionProvider` (if not at top level) or ensure hooks run inside it. - - Pass minimal props to children. - -## 4. Verification -- **Ribbon Functionality**: Verify buttons (Undo, Redo, Save) are active/disabled correctly via Action state. -- **Shortcuts**: Verify Ctrl+Z/Y work via the Action registry. -- **Layout**: Verify UI allows adding widgets/containers. diff --git a/packages/ui/docs/page-gen.md b/packages/ui/docs/page-gen.md deleted file mode 100644 index c319b294..00000000 --- a/packages/ui/docs/page-gen.md +++ /dev/null @@ -1,50 +0,0 @@ -# Reference Image Integration in Page Generator - -This document explains how user-selected reference images influence the generation process for both text and images within the AI Page Generator. - -## Overview - -When a user selects reference images in the AI Page Generator, these images are passed to the AI model (LLM) as part of the conversation context. This enables **multimodal generation**, where the AI can "see" the selected images and use that visual understanding to guide its output. - -## data Flow - -1. **Selection**: Users select images via the `ImagePickerDialog`. These are stored as `referenceImages` state in `AIPageGenerator`. -2. **Submission**: When "Generate" is clicked, the image URLs are collected and passed through `CreationWizardPopup` -> `usePageGenerator` -> `runTools`. -3. **Context Injection**: In `src/lib/openai.ts`, the `runTools` function detects the presence of images. It constructs a **multimodal user message** for the OpenAI API: - - ```json - { - "role": "user", - "content": [ - { "type": "text", "text": "User's text prompt..." }, - { "type": "image_url", "image_url": { "url": "https://..." } }, - { "type": "image_url", "image_url": { "url": "https://..." } } - ] - } - ``` - -## Impact on Generation - -### 1. Text Generation (Direct Visual Context) - -The LLM (e.g., GPT-4o) directly processes the image data. This allows it to: - -* Describe the visible content of the reference images in the generated page. -* Match the tone, style, and mood of the text to the visual aesthetics of the images. -* Extract specific details (colors, objects, setting) from the images and incorporate them into the narrative. - -### 2. Image Generation (Indirect Prompt Alignment) - -Currently, **reference images are NOT passed as direct inputs** (img2img) to the underlying image generation tools (`generate_image` or `generate_markdown_image`). - -Instead, the reference images influence image generation **indirectly via the LLM**: - -1. The LLM "sees" the reference images and understands their style, composition, and subject matter. -2. When the LLM decides to generating *new* images for the page (using `generate_text_with_images`), it writes the **image generation prompts** based on this visual understanding. -3. **Result**: The newly generated images are likely to be stylistically consistent with the reference images because the prompts used to generate them were crafted by an AI that "saw" the references. - -## Schema Reference - -* **`runTools` (`openai.ts`)**: Accepts `images: string[]` and builds the multimodal message. -* **`generate_text_with_images` (`markdownImageTools.ts`)**: Accepts text prompts for new images, but does not accept input images. -* **`generate_image` (`openai.ts`)**: Accepts text prompts, count, and model, but does not accept input images. diff --git a/packages/ui/docs/page-variables.md b/packages/ui/docs/page-variables.md new file mode 100644 index 00000000..dae71db5 --- /dev/null +++ b/packages/ui/docs/page-variables.md @@ -0,0 +1,44 @@ +# Page Variables + +The Page Variables system allows you to toggle different UI elements of a User Page via settings defined in the page itself or its parent contexts. These variables are stored in a page's metadata under `userVariables`. + +## Available System Variables + +| Variable | Type | Default | Description | +|---|---|---|---| +| `showTitle` | `boolean` | `true` | Toggles the display of the main page title block. | +| `showAuthor` | `boolean` | `true` | Toggles the display of the author's name. | +| `showDate` | `boolean` | `true` | Toggles the display of the publish date. | +| `showCategories` | `boolean` | `true` | Toggles the display of the category paths. | +| `showActions` | `boolean` | `true` | Toggles the display of the top-right page actions menu. | +| `showParent` | `boolean` | `true` | Toggles the display of the parent page path above the title. | +| `showToc` | `boolean` | `true` | Toggles the display of the side table of contents and mobile TOC. | +| `showLastUpdated` | `boolean` | `true` | Toggles the display of the "Last updated" footer at the bottom of the page. | +| `showFooter` | `boolean` | `true` | Toggles the display of the global app footer site-wide. | + +## Implementation References + +Here is how the Page Variables system is structured and implemented across the codebase: + +### 1. Variables Definition & Defaults +- [src/lib/page-variables.ts](../src/lib/page-variables.ts) + Defines `globalVariables` which sets the default fallbacks (e.g. `showTitle: true`, `showFooter: true`). Also provides the utility `mergePageVariables()` which resolves variable inheritance from a page's parent paths (organizations/categories). + +### 2. Editor & Schema +- [src/components/variables/VariablesEditor.tsx](../src/components/variables/VariablesEditor.tsx) + The editor component that allows users to toggle these settings. It defines the `SYSTEM_VARIABLE_SCHEMA` mapping keys like `showToc` and `showLastUpdated` to human-readable labels and descriptions. + +### 3. Usage in Page Rendering +- [src/modules/pages/UserPage.tsx](../src/modules/pages/UserPage.tsx) + Retrieves the resolved `contextVariables` using `mergePageVariables()` and uses them to determine high-level layout elements. + - Determines `showToc` and `hasTocContent` for toggling the Sidebar and Mobile Table of Contents. + - Determines `showLastUpdated` for rendering the footer details. + - Computes `showFooter` and passes it to the global Zustand AppStore to toggle the global application footer. +- [src/modules/pages/editor/UserPageDetails.tsx](../src/modules/pages/editor/UserPageDetails.tsx) + Retrieves various variables (`showTitle`, `showAuthor`, `showDate`, `showCategories`, `showActions`, `showParent`) and conditionally renders those blocks. If all are false and the page is not in edit mode, it avoids rendering the details wrapper entirely via an early return block. + +### 4. Global State (e.g., Global Footer) +- [src/store/appStore.ts](../src/store/appStore.ts) + A simple Zustand store containing `showGlobalFooter` and `setShowGlobalFooter`. Used as a communication bridge. +- [src/App.tsx](../src/App.tsx) + Reads `showGlobalFooter` from `useAppStore()` and decides whether to render the `
` component in the main application layout wrapper. diff --git a/packages/ui/docs/places/gadm.md b/packages/ui/docs/places/gadm.md new file mode 100644 index 00000000..2f3dd6d1 --- /dev/null +++ b/packages/ui/docs/places/gadm.md @@ -0,0 +1,54 @@ +# GADM Picker Implementation details + +This document covers the architectural and interaction details of the global bounds and region picker system. +The system connects an interactive `` frontend map down to a PostGIS + Martin + PMTiles mapping backend. + +## Architecture & Paths + +- **Main Component**: [`GadmPicker.tsx`](../../src/modules/places/gadm-picker/GadmPicker.tsx) +- **Local Searches & IO**: [`client-searches.ts`](../../src/modules/places/gadm-picker/client-searches.ts) +- **Server Application (Vite / Express)**: [`server.ts`](../../packages/gadm/server.ts) + +## API Endpoints (`/api/gadm/*`) + +The picker orchestrates several custom endpoints for real-time geographic data validation, mostly routed through Express in `packages/gadm/server.ts`: + +- **`GET /search?q={query}&level={level}`** + Searches the PostGIS database `gadm` view for any names matching the search vector. Often utilizes Redis caching to speed up autocomplete responses. +- **`GET /hierarchy?lat={lat}&lng={lng}`** + Triggers a point-based intersection against the `gadm` multi-polygons (`ST_Intersects`). Returns the full hierarchy (Level 0 through 5) containing the given coordinate. +- **`GET /boundary?gid={gid}&targetLevel={level}&enrich={bool}`** + Returns the exact geographic boundaries of a target region as a GeoJSON FeatureCollection. + - To maintain UI performance on large sets, queries the requested `targetLevel` limit to simplify rendering visually. + - Can optionally `enrich` the returned properties with Population and Area sizing dynamically from the PG backend. + - GeoJSON responses are statically cached locally to `packages/gadm/data/boundaries/` to ensure lightning-fast subsequent fetches. + +## The Map Inspector + +The map view uses MapLibre GL JS pointing to a local Martin vector tile server serving `.pmtiles`. +- **Point Queries**: Clicking anywhere on the unselected tiles translates the event into a `lat, lng` inspection. +- **Hierarchy Render**: This invokes `/hierarchy` and generates a list of administrative boundaries encompassing that specific point (from Nation down to County). +- **Highlighting**: Hovering over any inferred hierarchy option loads its bounding box dynamically (`gadm-picker-highlight`) to review before formally "adding". + +## Selection Lifecycle + +The selection state `selectedRegions` tracks picked regions across the UI. Due to API speeds and GeoJSON size considerations, the component features a highly customized, safe, interruptible multi-selection architecture: + +- **Single Select (Default)** + Clicking an autocomplete result or clicking to inspect on the map triggers a single-select wipe. This safely terminates any currently loading polygons, cancels queued network requests, and instantly drops all existing items from the array to maintain focus down to a single element. + +- **Multi-Select Queue (`ctrl + click` / `โŒ˜ + click`)** + If the `ctrl` key is held either on the autocomplete result, the inspector UI "Add" button, or on raw Map inspection clicks, the interactions skip cancellation logic. They are placed into a `queuedInspectionsRef` Set. Network resolutions occur concurrently and stack natively into the interface. + +- **Import / Export Portability** + The `` exposes IO tools to manage large or heavily tailored multi-select combinations: + - **Copy Config**: Translates the active GIDs and target levels directly to the local clipboard. + - **Export JSON**: Creates a local Blob URL and downloads the `selectedRegions` metadata explicitly (excludes raw poly-data to maintain strict minimalist file sizes). + - **Import JSON**: Triggers a hidden file input ``. Firing an import automatically wipes the active UI state and iteratively pushes all imported regions into the `ctrl+click` style high-speed multi-select queue to render perfectly. + +## Boundaries (`setGeojsons`) + +After an entity enters `selectedRegions`, its exact representation is rendered securely on the map with the ID layer `gadm-picker-features`. +- A background `useEffect` strictly manages sync loops formatting the multiple separate boundaries into a unified `FeatureCollection`, updating the MapLibre source in real-time. +- Regions feature small layout indicators (e.g. `L0`, `L2`) mapping directly to the `targetLevel` rendering logic determining boundary complexity. + diff --git a/packages/ui/docs/poolypress/editor.md b/packages/ui/docs/poolypress/editor.md new file mode 100644 index 00000000..28a83b33 --- /dev/null +++ b/packages/ui/docs/poolypress/editor.md @@ -0,0 +1,7 @@ +# AI-Assisted Content Creation + +PoolyPress integrates powerful AI aids directly into your publishing workspace, fundamentally augmenting how you create and assemble content. + +- **Interactive Chat Panel:** Converse directly with your content using the built-in Chat interface. You can ask the AI to draft text, attach media, or even directly drop in interactive gallery widgets. The panel is fully context-aware and hooks directly into the site's own internal search engine, allowing the AI to instantly find, query, and link your existing posts, pages, and categories. +- **AI Layout Wizard:** Say goodbye to starting from a blank page. Describe what you wantโ€”like "a product page with a hero image, a 2-column features grid, and a reviews section"โ€”and the AI Layout Wizard autonomously generates the exact widget containers needed. It reads your page's live template variables to produce highly realistic, data-driven placeholder content. +- **Contextual Inline Tools:** Within the editor itself, intelligent markdown tools let you rewrite, expand, summarize, and translate your prose on the flyโ€”ensuring your copy is always beautifully tailored for your target audiences without ever leaving your workflow. diff --git a/packages/ui/docs/poolypress/feed.PNG b/packages/ui/docs/poolypress/feed.PNG new file mode 100644 index 00000000..d435241d Binary files /dev/null and b/packages/ui/docs/poolypress/feed.PNG differ diff --git a/packages/ui/docs/poolypress/homefeed.md b/packages/ui/docs/poolypress/homefeed.md new file mode 100644 index 00000000..1d7a53d8 --- /dev/null +++ b/packages/ui/docs/poolypress/homefeed.md @@ -0,0 +1,7 @@ +# Editable Home & User Profiles + +PolyMech's home and user pages are fully customizable, avoiding rigid templates in favor of a dynamic, extensible widget system. + +- **Versatile Layouts:** Seamlessly toggle between robust grid, list, and large gallery views to best showcase your feeds. +- **Extensible Widgets:** Effortlessly drag and drop interactive elementsโ€”from media sliders to text contentโ€”into your pages, powered by an integrated widget registry. +- **Mobile-First & Responsive:** Every layout is highly responsive, guaranteeing that your feeds and pages deliver a flawless viewing experience across all devices. diff --git a/packages/ui/docs/poolypress/i18n.md b/packages/ui/docs/poolypress/i18n.md new file mode 100644 index 00000000..1d379d1b --- /dev/null +++ b/packages/ui/docs/poolypress/i18n.md @@ -0,0 +1,9 @@ +# Localization & i18n + +Reaching a global audience requires more than just translating simple UI strings. The Poolypress platform is designed with a deep, structurally-aware translation pipeline. + +- **Granular Widget Translations:** Instead of duplicating entire pages, translations are managed on a granular, widget-by-widget basis. This means you can update a single paragraph or label without disturbing the rest of the localized layout. +- **Batch Translation UI:** A powerful built-in dashboard allows you to filter and batch-translate missing content across entities (Categories, Pages, Custom Types) directly using DeepL's advanced engines. +- **DeepL Integration & Custom Glossaries:** Maintain total terminology consistency across your organization. Create bi-directional glossaries and synchronize them with DeepL to ensure brand terms and technical jargon are always translated correctly. +- **Specialized Markdown & Excel (XLSX) Support:** The translation pipeline goes beyond simple plaintext. We provide robust support for formatting-aware Specialized Markdown, and allow exporting and importing structured translations via Excel spreadsheets (XLSX). +- **Industry Standard Export (TMS):** We recognize that enterprise teams often rely on external Translation Management Systems (TMS) like Crowdin or Lokalise. The system natively supports extracting your content structure into standard formats (like XLIFF 2.0 or Flat JSON) so human translators can work in the tools they prefer, and inject the results safely right back into the platform. diff --git a/packages/ui/docs/poolypress/images.md b/packages/ui/docs/poolypress/images.md new file mode 100644 index 00000000..598b9c0a --- /dev/null +++ b/packages/ui/docs/poolypress/images.md @@ -0,0 +1,11 @@ +# AI Image Wizard + +The PoolyPress AI Image Wizard provides a unified studio for creating, editing, and managing assets using the latest generative models. + +- **Multi-Provider Models:** Generate and refine images by seamlessly switching between top-tier AI models (e.g., Gemini 3.1 Flash, OpenAI, etc.). +- **Grounding & Search:** Toggle options like Google Search and Image Search grounding for enhanced visual accuracy and real-world relevancy. +- **Versioning:** Instantly spin off variations of an image (e.g., applying new styles, background removals) while maintaining a strict, non-destructive history of all iterations. +- **Intelligent Prompting:** Lean on built-in prompt templates, save your own presets, automatically optimize rough prompts using AI, and traverse your complete prompt history. +- **Workflow Automation:** Chain together repeatable actionsโ€”like generating the image, applying a specific style, drafting metadata, and prepping for immediate SEO-optimized publicationโ€”in a single click. +- **Quick Styling Presets:** Apply highly-tuned style presets (e.g., Enhance, Make Artistic, Cyberpunk, Portrait) directly via the quick action buttons underneath the editor. +- **Agentic Tool Integration:** The entire image generation pipeline is exposed as tools to the AI across the platform. This enables the AI assistant to autonomously write comprehensive articles and generate highly detailed, brand-aligned, and context-aware illustrations right where they are needed. diff --git a/packages/ui/docs/poolypress/readme.md b/packages/ui/docs/poolypress/readme.md new file mode 100644 index 00000000..c9051e03 --- /dev/null +++ b/packages/ui/docs/poolypress/readme.md @@ -0,0 +1,72 @@ +# Platform Overview + +Built on 20 years of experience traversing the web development landscapeโ€”from traditional CMS giants like Joomla, Drupal, and WordPress, to modern static site generators such as Jekyll, Hugo, and recently Astroโ€”PoolyPress represents the evolution of content management. It aims to provide an all-in-one, one-stop-shop experience tailored for makers, developers, and small companies. Designed as a lightweight, drop-in replacement, it operates with minimal dependencies, ensuring it runs efficiently even on low-spec devices. + +PolyMech is a comprehensive, SEO-first, and highly secure full-stack media platform. It is designed to handle media management, rich content publishing, internationalization, and e-commerce, all while providing an exceptional developer and user experience. + +## Core Features & Benefits + +### ๐Ÿ“ธ Media Management & Delivery + +Effortlessly organize and deliver high-quality media content to your audience. + +* **Automated Media Pipelines:** Upload, process, and serve images and video with automatic intelligent format conversion, responsive scaling, and transcoding for smooth, adaptive playback. +* **Advanced Galleries & Versioning:** Manage multiple picture versions, utilize filmstrip navigation, and organize media through intuitive drag-and-drop. +* **Virtual File System (VFS):** A robust abstraction over local and remote storage featuring configurable mounting, personal user storage, robust access controls, and path-traversal protection. + +### ๐Ÿ“ฑ Seamless Mobile Experience & PWA + +Offer users a native app-like experience directly from their browser. + +* **Installable PWA:** Add to the home screen with offline precaching for fast, reliable access. +* **Native Device Sharing:** Leverage the Web Share Target API to share content from any mobile app straight into PolyMech for instant publishing. +* **Global Drag-and-Drop:** Intuitive upload workflows let you start a new post simply by dropping files or URLs anywhere on the page. + +### ๏ฟฝ Rich Content & Publishing + +Create engaging, interactive long-form content and dynamic feeds. + +* **Widget-Based Editor:** Build dynamic pages using an MDX-powered block editor featuring built-in galleries, video banners, carousels, and custom embeddable widgets. +* **Extensive Export Options:** Publish visually rich pages, organize them into collections, and easily export them to standalone HTML, PDF, Markdown, JSON, or Email formats. +* **Smart Discovery Feeds:** Aggregated home feeds offer powerful sorting, search functionality, advanced tagging, and category-based access control. + +### ๐ŸŒ Global Internationalization + +Reach a worldwide audience seamlessly with native multi-language support. + +* **Automated Translation:** Leverage AI-powered integration for batch translations of your content, ensuring that formatting and structure are perfectly preserved. +* **Granular i18n Control:** Every individual widget, title, and description can be translated independently, providing a deeply localized user experience. +* **Global SEO Readiness:** Automatically injects locale-aware tags, per-language sitemaps, and translated meta descriptions to secure top search rankings across regions. + +### ๐Ÿ” SEO, Discoverability, & AI-Readiness + +PolyMech is built from the ground up to be fully discoverable by search engines, social media platforms, AI agents, and content readersโ€”no external plugins required. + +* **Multi-Format Content Delivery:** Every piece of content can be instantly accessed in multiple formats on the fly with zero configuration. +* **Rich Meta & Structured Data:** The server automatically generates and injects Open Graph data, Twitter Cards, and context-aware JSON-LD schemas directly into the HTML for brilliant link previews. +* **Blazing Fast Performance:** Pre-fetches hydration data, provides edge-cached responsive images, and employs smart route code splitting ensuring instant page loads. +* **Syndication Feeds:** Includes built-in RSS/Atom feeds and auto-generated Merchant XML product feeds for integrated e-commerce discoverability. +* **LLM Discoverability:** Specialized machine-readable endpoints allow AI agents (like ChatGPT or Claude) to seamlessly ingest, understand, and cite your site's content. + +### ๐Ÿ›’ Integrated E-Commerce (Optional Module) + +Turn your platform into a fully-fledged storefront. + +* **Frictionless Shopping:** Complete end-to-end shopping experience featuring secure cart management, streamlined checkout, shipping integrations, and automated return handling. + +### ๐Ÿค– AI-Powered Content Creation + +Accelerate your creative workflow with deeply integrated AI tools. + +* **Context-Aware Assistance:** Connect with an AI assistant equipped with real-world tool execution capabilities right in your publishing workspace. +* **Inline Editing Magic:** Rewrite, expand, summarize, or translate text directly inside the editor without breaking your flow. +* **Autonomous Generation:** Empower the AI to build complete, structured layout pages and generate high-quality images straight from simple text prompts. + +### ๐Ÿ›ก๏ธ Enterprise-Grade Security + +Your platform is hardened against vulnerabilities and bad actors at every level, with policies applied consistently across all endpoints. + +* **Strict Authentication & Authorization:** Implements secure JWT-based authentication combined with declarative route-level access controls and strict role-based admin privileges. +* **Automated Threat Mitigation:** Actively protects your infrastructure with configurable IP-based rate limiting, manual blocklisting, and an intelligent Auto-Ban system that tracks violations to permanently block malicious actors. +* **Comprehensive Transport Protection:** Implements stringent secure headers, Content Security Policies (CSP), and Cross-Origin directives to effectively thwart XSS, clickjacking, and CSRF attacks without impacting load times. +* **Deep Observability & Auditing:** Real-time logging and background geocoding provide a detailed footprint of user activity, with live streaming available for active threat monitoring and system health tracking. diff --git a/packages/ui/docs/poolypress/security.md b/packages/ui/docs/poolypress/security.md new file mode 100644 index 00000000..68698bdb --- /dev/null +++ b/packages/ui/docs/poolypress/security.md @@ -0,0 +1,24 @@ +# Enterprise-Grade Security & Access Control + +PoolyPress is hardened against vulnerabilities and bad actors at every level, with policies applied consistently across all endpoints. Beyond standard authentication and transport protection, the platform features a highly granular **Virtual File System (VFS) Access Control List (ACL)** system for precise resource management. + +## Virtual File System (VFS) ACLs + +The Storage product implements a full ACL system for its virtual file system, ensuring that every file and folder is strictly access-controlled. + +- **Isolated Mounts:** Storage is divided into isolated namespaces called "Mounts." Each mount has its own set of access control rules. +- **Granular Permissions:** Access can be explicitly granted or revoked per user, per group, or per specific path within a mount. Supported permissions include `read`, `write`, `list`, `mkdir`, `delete`, `rename`, and `copy`. +- **Special Roles:** + - **Anonymous Access:** Configurable permissions for unauthenticated users (e.g., public read/list access). + - **Authenticated Users:** Configurable baseline permissions for any logged-in user. +- **Resource Chains:** Permissions are evaluated hierarchically. If a user tries to access `/docs/sub/file.txt`, the system checks grants on `/docs/sub/file.txt`, then `/docs/sub`, then `/docs`, and finally `/`. If any level grants access, the request succeeds. +- **Visual Editor:** The platform includes an intuitive ACL Editor UI (`AclEditor`) that allows administrators to easily toggle anonymous/authenticated access, grant specific permissions to individual users or groups, and revoke active permissions. + +## Comprehensive Security Posture + +The VFS ACLs operate within a broader, deeply integrated security architecture: + +- **Strict Authentication & Authorization:** Implements secure JWT-based authentication combined with declarative route-level access controls (`@Public`, `@Admin`). +- **Automated Threat Mitigation (Auto-Ban):** Actively protects your infrastructure by tracking rate limit violations and suspicious patterns in-memory, dynamically banning offending IP addresses, user IDs, or specific tokens. +- **Comprehensive Transport Protection:** Employs stringent secure headers, Content Security Policies (CSP), and Cross-Origin directives to effectively thwart XSS, clickjacking, and CSRF attacks without impacting load times. +- **Deep Observability & Auditing:** Real-time logging, background async geo-lookups, and SSE streams provide a detailed footprint of user activity for active threat monitoring and system health tracking. diff --git a/packages/ui/src/modules/posts/types-pictures.ts b/packages/ui/docs/poolypress/vfs.md similarity index 100% rename from packages/ui/src/modules/posts/types-pictures.ts rename to packages/ui/docs/poolypress/vfs.md diff --git a/packages/ui/docs/posts.md b/packages/ui/docs/posts.md index 45b5c740..0e171ea3 100644 --- a/packages/ui/docs/posts.md +++ b/packages/ui/docs/posts.md @@ -1,73 +1,236 @@ -# Plan: Support Multiple Images/Videos per Post +# Posts Module Documentation -## Overview -Currently, the application treats every image or video as an independent entity (a `picture` or linked `video`). We want to introduce a higher-level concept of a **Post** which can contain one or more media items (images or videos). +> **Module path:** `src/modules/posts/` -This allows: -- Grouping related photos (e.g., a photo dump, or variations). -- A unified description and comment section for the group. -- Preserving individual interactions (likes/comments) on specific images within the post if desired (as per requirements). +The posts module owns all post-related frontend functionality: creating, editing, viewing, and managing posts containing mixed media (images, videos, embeds, external links). -## Database Schema Changes +--- -We will introduce a new `posts` table and link existing `pictures` to it. +## Directory Structure -### 1. New Table: `posts` -This table will hold the content for the "container". +``` +src/modules/posts/ +โ”œโ”€โ”€ EditPost.tsx # Create & edit page (/post/new, /post/:id/edit) +โ”œโ”€โ”€ NewPost.tsx # Legacy new-post page (/new) +โ”œโ”€โ”€ PostPage.tsx # Post detail page (/post/:id) +โ”œโ”€โ”€ client-posts.ts # Post API client (CRUD, feed, meta) +โ”œโ”€โ”€ client-pictures.ts # Picture/media API client (CRUD, comments, likes, versions) +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ PostComposer.tsx # Post editor form (title, description, settings, image list) +โ”‚ โ””โ”€โ”€ PostPicker.tsx # Dialog for selecting existing posts (append-to-post flow) +โ””โ”€โ”€ views/ + โ”œโ”€โ”€ types.ts # Core types (PostItem, PostMediaItem, PostSettings, etc.) + โ”œโ”€โ”€ adapters.ts # Data adapters (Supabase โ†’ frontend models) + โ”œโ”€โ”€ PostActions.ts # Post action utilities + โ”œโ”€โ”€ usePostActions.ts # Post action hooks + โ”œโ”€โ”€ utils.ts # Shared utilities + โ”œโ”€โ”€ db.ts # DB helpers + โ”œโ”€โ”€ llm.tsx # AI/LLM integration for posts + โ”œโ”€โ”€ components/ + โ”‚ โ”œโ”€โ”€ SmartLightbox.tsx # Full-screen media lightbox + โ”‚ โ”œโ”€โ”€ DeleteDialogs.tsx # Post/picture delete confirmation + โ”‚ โ”œโ”€โ”€ ExportDropdown.tsx # Export menu (markdown, zip, etc.) + โ”‚ โ”œโ”€โ”€ TikTokDialog.tsx # TikTok embed dialog + โ”‚ โ”œโ”€โ”€ TikTokEmbed.tsx # TikTok embed renderer + โ”‚ โ””โ”€โ”€ YouTubeDialog.tsx # YouTube embed dialog + โ””โ”€โ”€ renderers/ + โ”œโ”€โ”€ ArticleRenderer.tsx # Blog/article-style layout + โ”œโ”€โ”€ CompactRenderer.tsx # Default compact layout + โ”œโ”€โ”€ EmbedRenderer.tsx # oEmbed rendering (/embed/:id) + โ”œโ”€โ”€ ThumbsRenderer.tsx # Thumbnail grid layout + โ””โ”€โ”€ components/ + โ”œโ”€โ”€ CompactActionToolbar.tsx # Action bar (like, share, edit, etc.) + โ”œโ”€โ”€ CompactFilmStrip.tsx # Horizontal thumbnail strip + โ”œโ”€โ”€ CompactMediaDetails.tsx # Media metadata panel + โ”œโ”€โ”€ CompactMediaViewer.tsx # Main media viewport + โ”œโ”€โ”€ CompactPostHeader.tsx # Post header (author, date, categories) + โ”œโ”€โ”€ Gallery.tsx # Gallery grid component + โ”œโ”€โ”€ MobileGroupItem.tsx # Mobile-optimized post item + โ”œโ”€โ”€ MobileGroupedFeed.tsx # Mobile grouped feed view + โ””โ”€โ”€ SpyGlassImage.tsx # Zoom/spy-glass image viewer +``` -| Column | Type | Notes | -|Ref|---|---| -| `id` | `uuid` | Primary Key, default `gen_random_uuid()` | -| `user_id` | `uuid` | FK to `auth.users` (or profiles) | -| `title` | `text` | Main title of the post | -| `description` | `text` | Description/Caption for the whole post | -| `created_at` | `timestamptz` | default `now()` | -| `updated_at` | `timestamptz` | | -| `metadata` | `jsonb` | Flexible field for extra data | +--- -### 2. Update Table: `pictures` -We link media items to the post. +## Routes -- Add column `post_id`: `uuid`, FK to `posts(id)`. -- Add column `position`: `integer`, default 0. To order images within a post. +| Route | Component | Description | +|-------|-----------|-------------| +| `/post/new` | `EditPost` | Create a new post | +| `/post/:id` | `PostPage` | View post detail | +| `/post/:id/edit` | `EditPost` | Edit an existing post | +| `/video/:id` | `PostPage` | View video post (same renderer) | +| `/new` | `NewPost` | Legacy new-post page | +| `/wizard` | `Wizard` | AI Image Wizard (separate module, not post-specific) | -> **Note**: Videos are stored in the `pictures` table with `type='mux-video'`, so this change covers both images and videos. The separate `videos` table in Supabase appears unused by the current frontend. +Route definitions: [`src/App.tsx`](../src/App.tsx) -### 3. Update Table: `comments` and `likes` -Currently, `comments` and `likes` reference `picture_id`. -- **Requirement**: "we might have also comments, and descriptions for the parent 'post'". -- **Approach**: - - Add `post_id` to `comments` and `likes` tables (nullable). - - Or create `post_comments` / `post_likes` tables if cleaner. - - *Decision*: We will start with a simple structure where `posts` have their own `description` (already in table). For comments, we might need a unified comment system later or link comments to posts. For now, let's focus on `posts` containing `pictures`. +--- -## Migration Strategy (SQL) +## Supported Media Types -According to user feedback, **no backfill is required**. Old pictures will simply not be displayed in the new "Post" feed which will rely on the `posts` table. +Posts can contain mixed media items. Each `PostMediaItem` has a `type` field: -1. **Create `posts` table.** -2. **Alter `pictures` table**: Add `post_id` column. +| Type | Description | Source | +|------|-------------|--------| +| `supabase-image` | Uploaded image (stored in Supabase Storage) | File upload / drag-drop | +| `mux-video` | Video (processed by Mux) | File upload | +| `video-intern` | Legacy internal video | Migration | +| `page-external` | External link card (with OG metadata) | URL paste | +| `youtube` | YouTube embed | YouTube dialog | +| `tiktok` | TikTok embed | TikTok dialog | -## UI/UX Updates +--- -### Feed (`PhotoGrid.tsx`) -- Query `posts` instead of `pictures`. -- Fetch the first linked picture for the thumbnail. +## Display Modes (Renderers) -### Post Detail (`Post.tsx`) -- Route `/post/:id` will now accept a **Post ID**. -- Fetch Post metadata. -- Fetch associated Media Items (`select * from pictures where post_id = :id order by position`). +Posts support multiple display modes, configured via `post.settings.display`: -### Creation Wizard -- Allow granular updates: "Select Multiple Files". -- Create Post -> Upload all files -> Create Picture records linked to Post. +| Mode | Renderer | Description | +|------|----------|-------------| +| `compact` (default) | [`CompactRenderer`](../src/modules/posts/views/renderers/CompactRenderer.tsx) | Side-panel layout: media viewer + filmstrip + details | +| `article` | [`ArticleRenderer`](../src/modules/posts/views/renderers/ArticleRenderer.tsx) | Blog-style: wide images with inline markdown text | +| `thumbs` | [`ThumbsRenderer`](../src/modules/posts/views/renderers/ThumbsRenderer.tsx) | Grid of thumbnails | +| embed | [`EmbedRenderer`](../src/modules/posts/views/renderers/EmbedRenderer.tsx) | For `/embed/:id` oEmbed endpoint | -## Step-by-Step Implementation +The renderer is selected in [`PostPage.tsx`](../src/modules/posts/PostPage.tsx) based on the post's settings. -1. **Supabase Migration**: Create tables, run backfill script. -2. **Codebase - Types**: Update `types.ts` (re-run codegen). -3. **Codebase - API**: Update any fetch functions to use `posts`. -4. **UI - Feed**: Switch `PhotoGrid` to use `posts`. -5. **UI - Detail**: Rewrite `Post.tsx` to handle `Post` + `Media[]`. -6. **UI - Create**: Update upload logic. +--- + +## Client APIs + +### `client-posts.ts` + +Post-level CRUD operations, all authenticated via Supabase session: + +| Function | Method | Description | +|----------|--------|-------------| +| `fetchPostById(id)` | `GET /api/posts/:id` | Fetch single post with pictures | +| `fetchPostDetailsAPI(id)` | `GET /api/posts/:id` | Fetch with responsive image sizes/formats | +| `fetchPostsList(options)` | `GET /api/posts` | Paginated post list | +| `fetchFullPost(postId)` | Supabase RPC | Full post with all relations | +| `createPost(data)` | `POST /api/posts` | Create new post | +| `updatePostDetails(id, updates)` | `PATCH /api/posts/:id` | Update title, description, settings, meta | +| `updatePostMeta(id, meta)` | `PATCH /api/posts/:id` | Update meta only | +| `deletePost(id)` | `DELETE /api/posts/:id` | Delete post and associated pictures | +| `mapFeedPostsToMediaItems(posts)` | โ€” | Convert feed posts to PhotoGrid-compatible format | +| `augmentFeedPosts(posts)` | โ€” | Normalize API posts (add cover, author) | + +### `client-pictures.ts` + +Picture/media-level operations: + +| Function | Method | Description | +|----------|--------|-------------| +| `createPicture(picture)` | `POST /api/pictures` | Create picture record | +| `updatePicture(id, updates)` | `PATCH /api/pictures/:id` | Update picture metadata | +| `deletePicture(id)` | `DELETE /api/pictures/:id` | Delete single picture | +| `deletePictures(ids)` | `POST /api/pictures/delete-batch` | Batch delete | +| `fetchPictures(options)` | `GET /api/pictures` | List pictures with filters | +| `fetchPictureById(id)` | `GET /api/pictures/:id` | Single picture | +| `fetchMediaItemsByIds(ids)` | `GET /api/media-items` | Batch fetch by IDs | +| `fetchVersions(item)` | `GET /api/pictures/versions` | Version tree | +| `toggleLike(userId, pictureId)` | Supabase | Toggle like on picture | +| `unlinkPictures(ids)` | `POST /api/pictures/unlink` | Unlink from post | +| `upsertPictures(pictures)` | `POST /api/pictures/upsert` | Batch upsert | +| `fetchCommentsAPI(pictureId)` | `GET /api/pictures/:id/comments` | Fetch comments | +| `addCommentAPI(pictureId, content)` | `POST /api/pictures/:id/comments` | Add comment | +| `editCommentAPI(pictureId, commentId, content)` | `PATCH` | Edit comment | +| `deleteCommentAPI(pictureId, commentId)` | `DELETE` | Delete comment | +| `toggleCommentLikeAPI(pictureId, commentId)` | `POST` | Toggle comment like | + +--- + +## Core Types + +Defined in [`views/types.ts`](../src/modules/posts/views/types.ts): + +```typescript +// A media item attached to a post +type PostMediaItem = MediaItem & { + post_id: string | null; + renderKey?: string; +}; + +// The post entity +interface PostItem { + id: string; + title: string; + description: string | null; + user_id: string; + created_at: string; + updated_at: string; + pictures?: PostMediaItem[]; + settings?: PostSettings; + meta?: PostMeta; +} + +// Post display and visibility settings +interface PostSettings { + display?: 'compact' | 'thumbs'; + visibility?: 'public' | 'listed' | 'private'; + link?: string; // For link posts + image_url?: string; + thumbnail_url?: string; +} + +// Post metadata +interface PostMeta { + slug?: string; + categoryIds?: string[]; + [key: string]: any; +} +``` + +--- + +## Post Lifecycle + +### Creating a Post + +1. User navigates to `/post/new` (via CreationWizardPopup or direct URL) +2. [`EditPost.tsx`](../src/modules/posts/EditPost.tsx) renders in create mode (no `id` param) +3. User fills title, description, adds images via drag-drop or file picker +4. Configures visibility/display in PostComposer settings accordion +5. On publish: `publishImage()` from [`publishHandlers.ts`](../src/components/ImageWizard/handlers/publishHandlers.ts) creates the post via `createPost()`, uploads images, links them via `createPicture()` +6. Redirects to `/post/:id` + +### Editing a Post + +1. User clicks "Edit Post" on [`PostPage.tsx`](../src/modules/posts/PostPage.tsx) โ†’ navigates to `/post/:id/edit` +2. [`EditPost.tsx`](../src/modules/posts/EditPost.tsx) fetches post data from `fetchPostById()` +3. Converts existing pictures to `ImageFile[]` format for PostComposer +4. User modifies content; on save: `publishImage()` calls `updatePostDetails()` and updates pictures +5. Redirects back to `/post/:id` + +### Viewing a Post + +1. [`PostPage.tsx`](../src/modules/posts/PostPage.tsx) fetches post via `fetchPostDetailsAPI()` +2. Resolves display mode from `post.settings.display` +3. Delegates to appropriate renderer (Compact, Article, Thumbs) +4. Renderer receives all props via `PostRendererProps` interface + +--- + +## Integration with ImageWizard + +The ImageWizard (`src/components/ImageWizard.tsx`) is a **separate module** for AI image generation. It interacts with posts in two ways: + +1. **Append to Post** โ€” Images generated in the wizard can be appended to an existing post via `PostPicker` dialog +2. **Open in Wizard** โ€” Individual post images can be opened in the wizard for AI-powered editing (variations, upscaling, etc.) + +The shared interface is the `ImageFile` type from [`ImageWizard/types.ts`](../src/components/ImageWizard/types.ts) and the `publishImage()` function from [`publishHandlers.ts`](../src/components/ImageWizard/handlers/publishHandlers.ts). + +> **Note:** `publishHandlers.ts` still lives in `ImageWizard/handlers/` but is functionally post-module logic. It should be migrated to `modules/posts/` in a future cleanup. + +--- + +## Post Settings & Visibility + +| Setting | Values | Effect | +|---------|--------|--------| +| `visibility` | `public` / `listed` / `private` | Controls feed inclusion and access | +| `display` | `compact` / `article` / `thumbs` | Selects renderer | +| `link` | URL string | Attaches external link to post | + +Managed via the settings accordion in [`PostComposer.tsx`](../src/modules/posts/components/PostComposer.tsx). diff --git a/packages/ui/docs/product-workers.md b/packages/ui/docs/product-workers.md new file mode 100644 index 00000000..552e2ee3 --- /dev/null +++ b/packages/ui/docs/product-workers.md @@ -0,0 +1,148 @@ +# Polymech Product Worker Architecture + +The Polymech server uses a hybrid architecture that delegates heavy CPU-bound tasks or isolated background processing (like queues) to dedicated native Node.js **Worker Threads**, while keeping the main event loop responsive for HTTP handling. + +This document details how the worker thread system is configured, initialized, and integrated via IPC. + +--- + +## 1. Configuration & Registry (`[config/products.json](../server/config/products.json)`) + +Worker threads are defined in `config/products.json`. Each product specifies the number of dedicated threads it requires under the `workers` property. + +```json +{ + "images": { + "enabled": true, + "workers": 1, + "routes": true + } +} +``` + +The boot sequence is managed by `[src/products/registry.ts](../server/src/products/registry.ts)`: +1. It reads `products.json`. +2. For every product with `workers > 0`, it spawns up to that many `Worker` instances. +3. The instantiated worker receives `workerData: { productName: 'images' }` so it knows which product it is responsible for. +4. The main thread maintains an in-memory array exported as `nativeWorkers` representing live worker threads. + +--- + +## 2. The Development Wrapper (`[worker_wrapper.mjs](../server/src/worker_wrapper.mjs)`) + +Because the project runs in development mode via `tsx watch` (or `vite-node`), natively spawned `Worker` threads lose the TypeScript transpilation context of the main thread. + +To fix this, `registry.ts` boots workers using a hybrid wrapper approach depending on the environment: + +- **Production**: Boots directly into `dist-server/worker.js`. +- **Development**: Boots into `src/worker_wrapper.mjs` instead of `worker.ts`. + +`worker_wrapper.mjs` manually sets up a `vite-node` API server inside the worker context to transpile `worker.ts` on the fly, and explicitly maps path aliases (like `@/*` to `src/*`) to resolve imports exactly like the main thread. + +--- + +## 3. The Worker Entrypoint (`[src/worker.ts](../server/src/worker.ts)`) + +Once `worker.ts` executes, it acts purely as an agnostic bootstrap layer. It performs no business logic of its own. + +Its responsibilities are: +1. Validate `workerData.productName`. +2. Instantiate the matched `AbstractProduct` subclass (e.g., `new ImagesProduct()`). +3. Set up the IPC event listener on `parentPort`. +4. Optionally boot PG-Boss (if the product requires queue management). +5. Call `instance.start()`. + +**IPC Routing:** +When `worker.ts` receives `{ type: 'job' }` messages from the main thread, it delegates the work directly back to the active product instance: + +```typescript +// inside worker.ts +if (msg.type === 'job') { + const { action, jobId } = msg; + try { + const result = await instance.handleJob(action, msg); + parentPort?.postMessage({ type: 'job_result', jobId, result }); + } catch (err) { + parentPort?.postMessage({ type: 'job_result', jobId, error: err.message }); + } +} +``` + +--- + +## 4. Main-to-Worker IPC (`[src/commons/worker-ipc.ts](../server/src/commons/worker-ipc.ts)`) + +To communicate with the worker, the main thread uses the `worker-ipc.ts` utility. +This file provides two critical functions: + +1. `hasWorker(productId: string)` โ€” A fast, synchronous check to see if a specific product (e.g., 'images') has an active native worker. +2. `dispatchToWorker(productId, action, payload, transferList?)` โ€” Wraps the internal Node.js `postMessage` architecture inside an easy-to-use Promise interface. + +### Important: Late Binding + +To avoid circular dependency cycles (`registry โ†’ images โ†’ worker-ipc โ†’ registry`), the IPC module does **not** import `nativeWorkers` at the top level. Instead, it dynamically imports it inside the function body (`await import('../products/registry.js')`). + +### Important: Zero-Copy Transfers + +When dispatching heavy workloads (like raw image ArrayBuffers), pass the buffer in the optional 4th parameter `transferList`. This effectively *moves* the memory block to the worker thread without taking the massive performance hit of serializing/cloning it over the IPC bridge. + +--- + +## 5. Domain Encapsulation (`[ImagesProduct.ts](../server/src/products/images/index.ts)`) + +To prevent `worker.ts` from becoming a bloated monolith, all business logic lives strictly inside the individual product classes (e.g., `ImagesProduct.ts`). + +Products running in hybrid mode override the `handleJob(action, msg)` method exposed by `AbstractProduct`. + +```typescript +// inside ImagesProduct.ts +async handleJob(action: string, msg: any): Promise { + if (action === 'process_image') { + // Runs inside the isolated worker thread + return await this.performProcessImage(...); + } + return super.handleJob(action, msg); +} +``` + +## 6. Hybrid Fallback (`[ImagesProduct.ts](../server/src/products/images/index.ts)`) + +Because the route handlers run on the *main thread*, they must account for scenarios where a worker has failed to boot, crashed, or was disabled in `config/products.json`. + +Route handlers implement a Graceful Fallback Pattern using `hasWorker()`: + +```typescript +// inside ImagesProduct.ts -> handlePostImage() +if (await hasWorker('images')) { + // 1. ArrayBuffer sent to worker (zero-copy) + await dispatchToWorker('images', 'process_image', { buffer, ... }, [buffer]); + // 2. Pick up the file the worker wrote to disk + processedBuffer = await fs.readFile(filepath); +} else { + // 1. Worker unavailable or disabled. Process synchronously inline. + await this.performProcessImage(inputBuffer, filepath, ...); + processedBuffer = await fs.readFile(filepath); +} +``` + +By ensuring that `performProcessImage` is a standalone class method, both the worker thread (`handleJob`) and the main thread fallback path execute the exact same, DRY codebase. + +--- + +## 7. Telemetry & Real-Time Admin Monitoring + +The admin interface provides live, deep telemetry into the performance and load of the native worker threads. This ensures developers can monitor memory consumption, CPU utilization, and task concurrency without external tools. + +**1. Task Concurrency (`activeJobs`)** +Inside `[src/worker.ts](../server/src/worker.ts)`, every incoming `'job'` IPC event increments an `activeJobs` internal counter. A `finally` block ensures this decrements when the job resolves or crashes. When the main thread requests a health check ping via `{ type: 'ping' }`, the worker thread replies with `{ type: 'pong', activeJobs }`. + +**2. Node.js Native Worker Metrics** +During periodic polling instances in `[src/endpoints/admin.ts](../server/src/endpoints/admin.ts)`, the main thread queries internal V8 and Node performance APIs for each running worker: +- **`getHeapStatistics()`**: Collects exact V8 heap memory usage (`heapUsedMB`, `heapTotalMB`). +- **`cpuUsage()`**: Produces microsecond-level CPU deltas which can be calculated into a real-time `cpuPercent`. +- **`performance.eventLoopUtilization()`**: Returns the fractional idle-vs-busy processing ratio for the worker thread, calculated into `eluPercent`. + +**3. WebSocket Push Broadcasting (`sys-stats`)** +Rather than relying on heavy REST API polling inside the Admin frontend UI, the backend uses a generic interval loop inside `admin.ts` to actively grab `getSystemInfo()` and `getWorkerPings()`. This aggregated telemetry is pushed automatically over WebSockets to all connected clients under the event `sys-stats` using `[src/commons/websocket.ts](../server/src/commons/websocket.ts)`. + +On the frontend, the `SystemStats` and `WorkerThreadStats` UI components simply tap into a global `useWebSocket` React Context Hook to listen for updates, updating visuals instantly with zero repetitive HTTP overhead. diff --git a/packages/ui/docs/refactor-filebrowser.md b/packages/ui/docs/refactor-filebrowser.md new file mode 100644 index 00000000..6a9fe4a3 --- /dev/null +++ b/packages/ui/docs/refactor-filebrowser.md @@ -0,0 +1,70 @@ +# FileBrowserPanel Refactoring Plan + +## 1. The Problem +`FileBrowserPanel.tsx` has grown to over 1300 lines. It currently handles: +- VFS state fetching (path, mount, nodes, search queries, filter resolution). +- Keyboard shortcuts and navigation logic (global arrow keys, F3 search overlay, focus trapping). +- Selection state (`selected`, `focusIdx`, multi-select logic). +- UI display parameters (viewMode, zoom state, dual/single layout sizes). +- A heavy rendering component tree (FileGridView, FileListView, FileTree, many modals, lightboxes, AILayout wizards). + +## 2. Refactoring Goals +We need to decouple the *data logic* from the *UI logic*, separate the heavy sub-views, and share common capabilities across different view renderers (`list`, `grid`, `tree`). + +Crucially, the file browser UI must become a generic "Shell". The `VFS` implementation should just be one **Adapter**. By extracting the generic `FileBrowserUI`, we can plug in other data adapters in the future (like `usePageAdapter` for `UserPage.tsx`), rendering arbitrary models (Pages, Posts, Contacts) with the exact same robust `Tree/List/Grid` explorer UI. + +### 2.1 Extract Custom Hooks + +We should split the huge block of `useState`/`useEffect` hooks into contextual hooks: + +- **`useVfsAdapter(mount, path, glob, accessToken)`** + Acts as the data provider. Translates VFS endpoints into standardized `INode` UI models. Handles fetching directories, caches, extracting `readme` data, resolving breadcrumbs. + *(Future adapters like `usePageAdapter` would conform to this exact same hook interface but fetch from `/api/pages`)* + - *Returns:* `nodes`, `sorted`, `loading`, `error`, `readmeContent`, `updatePath`, `updateMount`, `refresh()`. + +- **`useSelection(sortedNodes, onSelect)`** + Handles array of selected items, focus index, and logic to select single/multiple (`ctrlKey`/`shiftKey`) elements. We can share this directly with list, tree, and thumb renderers. + - *Returns:* `selected`, `focusIdx`, `setFocusIdx`, `setSelected`, `handleItemClick`, `clearSelection`. + +- **`useKeyboardNavigation(params)`** + Abstracts away global keybinds (e.g. F3 for search), container arrow navigation, copy/paste, backspace (up folder), and enter (open). + - Takes dependency on `focusIdx` and `selected` from `useSelection()`. + +- **`useFilePreview(accessToken)`** + Manages the state for open lightboxes (Image/Video, Docs, Iframe, Text) rather than having all state at the root of `FileBrowserPanel`. + - *Returns:* `lightboxNode`, `setLightboxNode`, `previewComponent` (pre-rendered JSX based on what's active). + +### 2.2 Break Out UI Sub-components + +Currently, the rendered output mixes complex file-fetching logic with the actual modal HTML. + +- **`FilePreviewContainer`** + Move all `Lightbox` instantiations (e.g., `ImageLightbox`, `SpreadsheetLightbox`, `ThreeDViewer`, `PdfLightbox`) into a single child component. Pass `selectedFile` or active view node to it. +- **`LayoutToolbarWrapper`** + Simplify how `FileBrowserToolbar` is rendered, connecting it purely to an abstracted state object rather than 30 independent props pass-throughs. +- **`SearchDialog` & `FilterDialog` Management** + Currently inline or tightly coupled; should be separated into a `DialogRenderer` wrapper or use a generic dialog context. + +### 2.3 Universal Interface for Viewers +The `Tree`, `List`, and `Grid` renderers all replicate basic file display logic. By passing a shared Context or generic store (e.g., a `ViewerControlContext`), each one can trigger: +- `openFile(INode)` +- `goUp()` +- `selectFile(INode, multi)` +This standardizes the event-actions instead of passing 10+ props. + +## 3. Recommended Actions & Phasing + +- ~~**Phase 1: State Extraction**~~ + Extract `useSelection` and `useFilePreview` hooks from `FileBrowserPanel.tsx` without moving component rendering. Validate that deep link auto-open and search selections still function correctly. +- ~~**Phase 2: VFS Extraction**~~ + Extract `useVFSBrowser` so directory fetching and sorting logic becomes completely separated from React rendering constraints. +- **Phase 3: Component Cleanup** + Move all `Lightbox...` conditionally rendered objects at the bottom of the file into a `` component layer. +- **Phase 4: Shared Navigation Interface** + Connect `FileTree`, `FileListView`, `FileGridView` to the shared `useSelection`/`useKeyboardNavigation` events so behavior is strictly unified without duplicate `
` trees to a static route tree). + +## Phase 1: Setup and Basic Configuration + +- [ ] **Install Dependencies** + - Run `npm install @tanstack/react-router @tanstack/router-vite-plugin` in `pm-pics`. + - Also install in `@polymech/ecommerce` if needed, or handle it as a peer dependency. +- [ ] **Vite Configuration** + - Add `TanStackRouterVite` to `vite.config.ts`. This enables file-based routing generation or static tree compilation. +- [ ] **Define the Root Route** + - Create `src/routes/__root.tsx`. + - Migrate the layout shell from `AppWrapper` into the Root Route, including: + - `` + - `` and `
` + - Context Providers (can stay in `App.tsx` surrounding the router provider, or move to the Root Route context). +- [ ] **Initialize the Router** + - In `src/App.tsx`, remove `` and initialize `const router = createRouter({ routeTree })`. + - Render with ``. + +## Phase 2: Route Declaration and Mapping + +- [ ] **Convert Static Routes** + - Convert basic pages like `/auth`, `/profile`, `/settings/providers`, `/playground/*`. + - Create route files (e.g., `src/routes/auth.tsx`) or define them in a static route tree file. +- [ ] **Convert Dynamic Routes** + - Map dynamic segments: + - `/post/:id` -> `src/routes/post.$id.tsx` + - `/user/:userId` -> `src/routes/user.$userId.tsx` + - `/tags/:tag` -> `src/routes/tags.$tag.tsx` + - `/collections/:userId/:slug` -> `src/routes/collections.$userId.$slug.tsx` + - Re-wire path parameter extraction from `useParams()` to TanStack's `Route.useParams()`. +- [ ] **Convert Catch-All / Wildcard Routes (Deeply Nested Apps)** + - Routes like `/categories/*` or `/app/filebrowser/*` will need to use TanStack's splat routing (e.g., creating a `$.tsx` file). + - Inside the component, use `Route.useParams()` to grab the `_splat` variable which contains the remaining path string. + - *Refactor Opportunity*: Consider converting deeply nested paths in nested apps (like mount/file/path) into strictly typed Search Parameters (e.g., `?mount=local&path=foo`) leveraging TanStack's built-in Zod validation. +- [ ] **Handle Global Not Found (404)** + - `*` (Not Found) needs to be handled via TanStack Router's built-in `NotFoundRoute` or router configuration, not a standard route definition. +- [ ] **Migrate Standalone Packages** + - `@polymech/ecommerce` (`EcommerceBundle.tsx`) relies on `react-router-dom` (`useLocation`, `matchPath`, `Navigate`). + - It needs to be refactored either entirely to TanStack Router, or decoupled so the host app injects the routing logic. + +## Phase 3: Component Refactoring (The Long Tail) + +- [ ] **Replace `useNavigate`** + - Find all instances of `useNavigate()` from `react-router-dom`. + - Replace with `useNavigate()` from `@tanstack/react-router`. + - **Crucial**: TanStack requires paths to match the route definitions strictly, which will catch broken links but requires updating all link definitions. +- [ ] **Replace ``** + - Update all `react-router-dom` `` with TanStack's ``. +- [ ] **Replace `useLocation` and `useSearchParams`** + - TanStack has robust typed search parameters. If `useSearchParams` is used for view modes, filtering, or tabs, define the search params schema on the Route level. +- [ ] **Replace `` components** + - Change programmatic `` to TanStack's equivalent or throw a redirect inside a route loader. + +## Phase 4: Enhancements (Optional but Recommended) + +- [ ] **Implement Pre-fetching** + - Move API calls currently wrapped in `useQuery` inside `useEffect` (like home feed loading) into TanStack's `loader` functions. This eliminates waterfalls. +- [ ] **Configure Scroll Restoration** + - Remove the custom `ScrollRestoration.tsx`. + - Enable `` provided by TanStack in the Root Route. +- [ ] **Analytics Interception** + - Hook analytics into the router context directly (e.g., `router.subscribe`), replacing the `onRouteChange` prop we added to the custom restorer. + +## Risk Assessment +- **Huge API surface change**: Touches roughly 30-50 files. +- **Ecommerce Package**: The `@polymech/ecommerce` bundle must be updated carefully so it still functions if imported independently. +- **Type errors**: Prepare for hundreds of TypeScript errors regarding route typings immediately after enabling the Vite plugin. diff --git a/packages/ui/docs/screenshots/01-discovery-feed.png b/packages/ui/docs/screenshots/01-discovery-feed.png new file mode 100644 index 00000000..4cd33f1e Binary files /dev/null and b/packages/ui/docs/screenshots/01-discovery-feed.png differ diff --git a/packages/ui/docs/screenshots/02-discovery-feed-top.png b/packages/ui/docs/screenshots/02-discovery-feed-top.png new file mode 100644 index 00000000..4efdf4ad Binary files /dev/null and b/packages/ui/docs/screenshots/02-discovery-feed-top.png differ diff --git a/packages/ui/docs/screenshots/03-categories.png b/packages/ui/docs/screenshots/03-categories.png new file mode 100644 index 00000000..853381ed Binary files /dev/null and b/packages/ui/docs/screenshots/03-categories.png differ diff --git a/packages/ui/docs/screenshots/04-post-detail.png b/packages/ui/docs/screenshots/04-post-detail.png new file mode 100644 index 00000000..c7e51ed6 Binary files /dev/null and b/packages/ui/docs/screenshots/04-post-detail.png differ diff --git a/packages/ui/docs/screenshots/05-user-profile.png b/packages/ui/docs/screenshots/05-user-profile.png new file mode 100644 index 00000000..cb6016fe Binary files /dev/null and b/packages/ui/docs/screenshots/05-user-profile.png differ diff --git a/packages/ui/docs/screenshots/06-page-detail.png b/packages/ui/docs/screenshots/06-page-detail.png new file mode 100644 index 00000000..ee657112 Binary files /dev/null and b/packages/ui/docs/screenshots/06-page-detail.png differ diff --git a/packages/ui/docs/screenshots/07-search-results.png b/packages/ui/docs/screenshots/07-search-results.png new file mode 100644 index 00000000..030aa623 Binary files /dev/null and b/packages/ui/docs/screenshots/07-search-results.png differ diff --git a/packages/ui/docs/screenshots/08-video-feed.png b/packages/ui/docs/screenshots/08-video-feed.png new file mode 100644 index 00000000..05cc2ae1 Binary files /dev/null and b/packages/ui/docs/screenshots/08-video-feed.png differ diff --git a/packages/ui/docs/screenshots/auth.png b/packages/ui/docs/screenshots/auth.png deleted file mode 100644 index a723192c..00000000 Binary files a/packages/ui/docs/screenshots/auth.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/cats.PNG b/packages/ui/docs/screenshots/cats.PNG new file mode 100644 index 00000000..9e51533b Binary files /dev/null and b/packages/ui/docs/screenshots/cats.PNG differ diff --git a/packages/ui/docs/screenshots/cms-boss.PNG b/packages/ui/docs/screenshots/cms-boss.PNG new file mode 100644 index 00000000..54f4d995 Binary files /dev/null and b/packages/ui/docs/screenshots/cms-boss.PNG differ diff --git a/packages/ui/docs/screenshots/debug-wizard-error.png b/packages/ui/docs/screenshots/debug-wizard-error.png deleted file mode 100644 index 94eb7c33..00000000 Binary files a/packages/ui/docs/screenshots/debug-wizard-error.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/design-annotation-system.png b/packages/ui/docs/screenshots/design-annotation-system.png deleted file mode 100644 index 119746c5..00000000 Binary files a/packages/ui/docs/screenshots/design-annotation-system.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/extruder-ex.PNG b/packages/ui/docs/screenshots/extruder-ex.PNG new file mode 100644 index 00000000..bf08063c Binary files /dev/null and b/packages/ui/docs/screenshots/extruder-ex.PNG differ diff --git a/packages/ui/docs/screenshots/extruder.PNG b/packages/ui/docs/screenshots/extruder.PNG new file mode 100644 index 00000000..008e9d00 Binary files /dev/null and b/packages/ui/docs/screenshots/extruder.PNG differ diff --git a/packages/ui/docs/screenshots/home.png b/packages/ui/docs/screenshots/home.png deleted file mode 100644 index f7300d6f..00000000 Binary files a/packages/ui/docs/screenshots/home.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/i18n-batch.PNG b/packages/ui/docs/screenshots/i18n-batch.PNG new file mode 100644 index 00000000..0d23b5e4 Binary files /dev/null and b/packages/ui/docs/screenshots/i18n-batch.PNG differ diff --git a/packages/ui/docs/screenshots/i18n-glossaries.PNG b/packages/ui/docs/screenshots/i18n-glossaries.PNG new file mode 100644 index 00000000..db833d61 Binary files /dev/null and b/packages/ui/docs/screenshots/i18n-glossaries.PNG differ diff --git a/packages/ui/docs/screenshots/image-boss.PNG b/packages/ui/docs/screenshots/image-boss.PNG new file mode 100644 index 00000000..d2f61558 Binary files /dev/null and b/packages/ui/docs/screenshots/image-boss.PNG differ diff --git a/packages/ui/docs/screenshots/image-maps.PNG b/packages/ui/docs/screenshots/image-maps.PNG new file mode 100644 index 00000000..38c3749a Binary files /dev/null and b/packages/ui/docs/screenshots/image-maps.PNG differ diff --git a/packages/ui/docs/screenshots/images-tools.PNG b/packages/ui/docs/screenshots/images-tools.PNG new file mode 100644 index 00000000..e9abb3a4 Binary files /dev/null and b/packages/ui/docs/screenshots/images-tools.PNG differ diff --git a/packages/ui/docs/screenshots/mobile-feed.PNG b/packages/ui/docs/screenshots/mobile-feed.PNG new file mode 100644 index 00000000..cccfaadc Binary files /dev/null and b/packages/ui/docs/screenshots/mobile-feed.PNG differ diff --git a/packages/ui/docs/screenshots/new-post.png b/packages/ui/docs/screenshots/new-post.png deleted file mode 100644 index b386b94f..00000000 Binary files a/packages/ui/docs/screenshots/new-post.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/plunger.PNG b/packages/ui/docs/screenshots/plunger.PNG new file mode 100644 index 00000000..edaeba13 Binary files /dev/null and b/packages/ui/docs/screenshots/plunger.PNG differ diff --git a/packages/ui/docs/screenshots/post-compact.png b/packages/ui/docs/screenshots/post-compact.png deleted file mode 100644 index 976f9246..00000000 Binary files a/packages/ui/docs/screenshots/post-compact.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/search.png b/packages/ui/docs/screenshots/search.png deleted file mode 100644 index 429d92b2..00000000 Binary files a/packages/ui/docs/screenshots/search.png and /dev/null differ diff --git a/packages/ui/docs/screenshots/sec-admin.PNG b/packages/ui/docs/screenshots/sec-admin.PNG new file mode 100644 index 00000000..d75371e9 Binary files /dev/null and b/packages/ui/docs/screenshots/sec-admin.PNG differ diff --git a/packages/ui/docs/screenshots/types.PNG b/packages/ui/docs/screenshots/types.PNG new file mode 100644 index 00000000..2d1487cd Binary files /dev/null and b/packages/ui/docs/screenshots/types.PNG differ diff --git a/packages/ui/docs/screenshots/wizard.png b/packages/ui/docs/screenshots/wizard.png deleted file mode 100644 index d14c5418..00000000 Binary files a/packages/ui/docs/screenshots/wizard.png and /dev/null differ diff --git a/packages/ui/docs/search-ex.md b/packages/ui/docs/search-ex.md new file mode 100644 index 00000000..2f950252 --- /dev/null +++ b/packages/ui/docs/search-ex.md @@ -0,0 +1,1078 @@ +# Full-Text Search and VFS Indexing Architecture + +This document covers the end-to-end architecture for Full-Text Search (FTS) regarding the Virtual File System (VFS). It details the extraction of textual content from uploaded files all the way to performing concurrent WebSearch language processing algorithms native to PostgreSQL. + +## 1. Indexing & Text Extraction Pipeline + +When an administrator forces a VFS reindex with the `fullText` option, the server systematically walks the specified mount, performing strict checks to extract the file content safely into the database. + +### File Indexing Sequence + +```mermaid +sequenceDiagram + participant Admin as VFS Admin (UI) + participant UI as [StorageManager.tsx](../src/components/admin/StorageManager.tsx) + participant Route as [vfs-routes.ts](../server/src/products/storage/api/vfs-routes.ts) + participant VFS as [vfs.ts](../server/src/products/storage/api/vfs.ts) + participant FS as Local File System + participant DB as Supabase DB + + Admin->>UI: Select "Index All" + Full Text + UI->>Route: POST /api/vfs/index/{mount}?fullText=true + Route->>VFS: handleVfsIndex() + + loop Every File Found in Walk + VFS->>VFS: Check Type and Size (Limit: VFS_INDEX_MAX_FILE_SIZE_KB) + VFS->>VFS: Validate Extension against allowFullTextExtensions + + opt Passes Filters + VFS->>FS: Stream start readingz + FS-->>VFS: Output Stream chunks + VFS->>VFS: Verify NOT Binary (No Null Bytes) + end + + VFS->>DB: Upsert Node Data to vfs_index + Note right of DB: fts tsvector auto-updated
by trigger/stored procedure + end + + VFS-->>Route: Return Batched OK + Route-->>UI: OK +``` + +### Core Components +* **[StorageManager.tsx](../src/components/admin/StorageManager.tsx)**: UI providing the checkbox to trigger `fullText` parameter for reindexing. +* **[vfs.json](../server/config/vfs.json)**: Externalized configuration supporting `allowFullTextExtensions` defining valid target patterns (e.g. `md`, `cpp`). +* **[vfs.ts](../server/src/products/storage/api/vfs.ts)**: Contains the central mechanism inside `handleVfsIndex` that implements extraction via async pipeline strategies (filters and transformers) wrapped in race limits to block hanging operations. +* **[vfs_index.sql](../supabase/migrations/20260308120300_create_vfs_index.sql)**: Contains the table structure. Specifically, you can see `fts tsvector GENERATED ALWAYS AS (...)` mapped tightly to combine the `name`, `path`, and `content`. + +--- + +## 2. Searching & WebSearch Concurrency + +The main unified search bar queries `/api/search` which dispatches nested parallel logic depending on the internal components. For files, we natively blend strict regex string checks (`ilike`) and Deep WebSearch functionality (`textSearch`). + +### File Search Sequence + +```mermaid +sequenceDiagram + participant User + participant Router as [db-search.ts](../server/src/products/serving/db/db-search.ts) + participant VFS as [vfs.ts](../server/src/products/storage/api/vfs.ts) + participant Supabase + + User->>Router: GET /api/search?q=Anycubic+Chiron+Marlin&type=files + Router->>VFS: searchAllSearchableVfs(query) + + par Path Substring Match + VFS->>Supabase: ilike('path', '%Anycubic%') & ilike('path', '%Marlin%') + and FTS Content Match + VFS->>Supabase: .textSearch('fts', query, { type: 'websearch' }) + Note right of Supabase: Translates unquoted words to "&" organically + end + + Supabase-->>VFS: return [pathRes, ftsRes] + + VFS->>VFS: Map.set(row.id, deduplicated) + VFS-->>Router: Combined INode[] (with deep metadata) + Router->>Router: Enrich & Filter Visibility (ACL) + Router-->>User: Final Feed Items (meta.url mapped) +``` + +### Core Components +* **[db-search.ts](../server/src/products/serving/db/db-search.ts)**: Coordinates overarching feed blending globally. It processes hit arrays from various pipelines in parallel (Pages, Posts, Files etc.). +* **[vfs.ts](../server/src/products/storage/api/vfs.ts)**: Inside `searchAllSearchableVfs`, we issue the actual `.textSearch` querying function to Supabase and cleanly map any internal SQL errors safely to our standard error logging stream. +* **[search-fts.e2e.test.ts](../server/src/products/serving/__tests__/search-fts.e2e.test.ts)**: Validates exactly this layer ensuring end-to-end integration and data propagation accurately hit native database FTS queries safely. + +--- + +## 3. TODOs & Future Enhancements + +* **Google Search Bot Indexability**: Investigate and implement strategies for how web crawlers (like Googlebot) can discover and index our dynamic search results. Potential solutions include: + * **Server-Side Rendering (SSR) of Search Pages**: Ensuring initial page loads return fully populated HTML for search queries if accessed directly via URL parameters. + * **Dynamic Sitemaps (`sitemap.xml`)**: Generating sitemap endpoints that list popular or pre-computed search queries to guide crawlers. + * **Internal Linking Structure**: Exposing curated search links or tags throughout the application surface area (e.g., related topics, tag clouds) that crawlers can follow. + * **Structured Data**: Injecting JSON-LD or schema.org metadata representing the search capability and search result pages. + +# Full Text Search + +How to use full text search in PostgreSQL. + +Postgres has built-in functions to handle `Full Text Search` queries. This is like a "search engine" within Postgres. + +## Preparation + +For this guide we'll use the following example data: + +| id | title | author | description | +| --- | ----------------------------------- | ---------------------- | ------------------------------------------------------------------ | +| 1 | The Poky Little Puppy | Janette Sebring Lowrey | Puppy is slower than other, bigger animals. | +| 2 | The Tale of Peter Rabbit | Beatrix Potter | Rabbit eats some vegetables. | +| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | +| 4 | Green Eggs and Ham | Dr. Seuss | Sam has changing food preferences and eats unusually colored food. | +| 5 | Harry Potter and the Goblet of Fire | J.K. Rowling | Fourth year of school starts, big drama ensues. | + +```sql +create table books ( + id serial primary key, + title text, + author text, + description text +); + +insert into books + (title, author, description) +values + ( + 'The Poky Little Puppy', + 'Janette Sebring Lowrey', + 'Puppy is slower than other, bigger animals.' + ), + ('The Tale of Peter Rabbit', 'Beatrix Potter', 'Rabbit eats some vegetables.'), + ('Tootle', 'Gertrude Crampton', 'Little toy train has big dreams.'), + ( + 'Green Eggs and Ham', + 'Dr. Seuss', + 'Sam has changing food preferences and eats unusually colored food.' + ), + ( + 'Harry Potter and the Goblet of Fire', + 'J.K. Rowling', + 'Fourth year of school starts, big drama ensues.' + ); +``` + +## Usage + +The functions we'll cover in this guide are: + +### `to_tsvector()` [#to-tsvector] + +Converts your data into searchable tokens. `to_tsvector()` stands for "to text search vector." For example: + +```sql +select to_tsvector('green eggs and ham'); +-- Returns 'egg':2 'green':1 'ham':4 +``` + +Collectively these tokens are called a "document" which Postgres can use for comparisons. + +### `to_tsquery()` [#to-tsquery] + +Converts a query string into tokens to match. `to_tsquery()` stands for "to text search query." + +This conversion step is important because we will want to "fuzzy match" on keywords. +For example if a user searches for `eggs`, and a column has the value `egg`, we probably still want to return a match. + +Postgres provides several functions to create tsquery objects: + +- **`to_tsquery()`** - Requires manual specification of operators (`&`, `|`, `!`) +- **`plainto_tsquery()`** - Converts plain text to an AND query: `plainto_tsquery('english', 'fat rats')` โ†’ `'fat' & 'rat'` +- **`phraseto_tsquery()`** - Creates phrase queries: `phraseto_tsquery('english', 'fat rats')` โ†’ `'fat' <-> 'rat'` +- **`websearch_to_tsquery()`** - Supports web search syntax with quotes, "or", and negation + +### Match: `@@` [#match] + +The `@@` symbol is the "match" symbol for Full Text Search. It returns any matches between a `to_tsvector` result and a `to_tsquery` result. + +Take the following example: + +```sql +select * +from books +where title = 'Harry'; +``` + +```js +const { data, error } = await supabase.from('books').select().eq('title', 'Harry') +``` + +```dart +final result = await client + .from('books') + .select() + .eq('title', 'Harry'); +``` + +```swift +let response = try await supabase.from("books") + .select() + .eq("title", value: "Harry") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + eq("title", "Harry") + } +} +``` + +```python +data = supabase.from_('books').select().eq('title', 'Harry').execute() +``` + +The equality symbol above (`=`) is very "strict" on what it matches. In a full text search context, we might want to find all "Harry Potter" books and so we can rewrite the +example above: + +```sql +select * +from books +where to_tsvector(title) @@ to_tsquery('Harry'); +``` + +```js +const { data, error } = await supabase.from('books').select().textSearch('title', `'Harry'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('title', "'Harry'"); +``` + +```swift +let response = try await supabase.from("books") + .select() + .textSearch("title", value: "'Harry'") +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("title", "'Harry'", TextSearchType.NONE) + } +} +``` + +## Basic full text queries + +### Search a single column + +To find all `books` where the `description` contain the word `big`: + +```sql +select + * +from + books +where + to_tsvector(description) + @@ to_tsquery('big'); +``` + +```js +const { data, error } = await supabase.from('books').select().textSearch('description', `'big'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('description', "'big'"); +``` + +```swift +let response = await client.from("books") + .select() + .textSearch("description", value: "'big'") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'big'", TextSearchType.NONE) + } +} +``` + +```python +data = supabase.from_('books').select().text_search('description', "'big'").execute() +``` + +| id | title | author | description | +| --- | ----------------------------------- | ----------------- | ----------------------------------------------- | +| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | +| 5 | Harry Potter and the Goblet of Fire | J.K. Rowling | Fourth year of school starts, big drama ensues. | + +### Search multiple columns + +Right now there is no direct way to use JavaScript or Dart to search through multiple columns but you can do it by creating [computed columns](https://postgrest.org/en/stable/api.html#computed-virtual-columns) on the database. + +To find all `books` where `description` or `title` contain the word `little`: + +```sql +select + * +from + books +where + to_tsvector(description || ' ' || title) -- concat columns, but be sure to include a space to separate them! + @@ to_tsquery('little'); +``` + +```sql +create function title_description(books) returns text as $$ + select $1.title || ' ' || $1.description; +$$ language sql immutable; +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('title_description', `little`) +``` + +```sql +create function title_description(books) returns text as $$ + select $1.title || ' ' || $1.description; +$$ language sql immutable; +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('title_description', "little") +``` + +```sql +create function title_description(books) returns text as $$ + select $1.title || ' ' || $1.description; +$$ language sql immutable; +``` + +```swift +let response = try await client + .from("books") + .select() + .textSearch("title_description", value: "little") + .execute() +``` + +```sql +create function title_description(books) returns text as $$ + select $1.title || ' ' || $1.description; +$$ language sql immutable; +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("title_description", "title", TextSearchType.NONE) + } +} +``` + +```sql +create function title_description(books) returns text as $$ + select $1.title || ' ' || $1.description; +$$ language sql immutable; +``` + +```python +data = supabase.from_('books').select().text_search('title_description', "little").execute() +``` + +| id | title | author | description | +| --- | --------------------- | ---------------------- | ------------------------------------------- | +| 1 | The Poky Little Puppy | Janette Sebring Lowrey | Puppy is slower than other, bigger animals. | +| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | + +### Match all search words + +To find all `books` where `description` contains BOTH of the words `little` and `big`, we can use the `&` symbol: + +```sql +select + * +from + books +where + to_tsvector(description) + @@ to_tsquery('little & big'); -- use & for AND in the search query +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', `'little' & 'big'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('description', "'little' & 'big'"); +``` + +```swift +let response = try await client + .from("books") + .select() + .textSearch("description", value: "'little' & 'big'"); + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'title' & 'big'", TextSearchType.NONE) + } +} +``` + +```python +data = supabase.from_('books').select().text_search('description', "'little' & 'big'").execute() +``` + +| id | title | author | description | +| --- | ------ | ----------------- | -------------------------------- | +| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | + +### Match any search words + +To find all `books` where `description` contain ANY of the words `little` or `big`, use the `|` symbol: + +```sql +select + * +from + books +where + to_tsvector(description) + @@ to_tsquery('little | big'); -- use | for OR in the search query +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', `'little' | 'big'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('description', "'little' | 'big'"); +``` + +```swift +let response = try await client + .from("books") + .select() + .textSearch("description", value: "'little' | 'big'") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'title' | 'big'", TextSearchType.NONE) + } +} +``` + +```python +response = client.from_('books').select().text_search('description', "'little' | 'big'").execute() +``` + +| id | title | author | description | +| --- | --------------------- | ---------------------- | ------------------------------------------- | +| 1 | The Poky Little Puppy | Janette Sebring Lowrey | Puppy is slower than other, bigger animals. | +| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | + +Notice how searching for `big` includes results with the word `bigger` (or `biggest`, etc). + +## Partial search + +Partial search is particularly useful when you want to find matches on substrings within your data. + +### Implementing partial search + +You can use the `:*` syntax with `to_tsquery()`. Here's an example that searches for any book titles beginning with "Lit": + +```sql +select title from books where to_tsvector(title) @@ to_tsquery('Lit:*'); +``` + +### Extending functionality with RPC + +To make the partial search functionality accessible through the API, you can wrap the search logic in a stored procedure. + +After creating this function, you can invoke it from your application using the SDK for your platform. Here's an example: + +```sql +create or replace function search_books_by_title_prefix(prefix text) +returns setof books AS $$ +begin + return query + select * from books where to_tsvector('english', title) @@ to_tsquery(prefix || ':*'); +end; +$$ language plpgsql; +``` + +```js +const { data, error } = await supabase.rpc('search_books_by_title_prefix', { prefix: 'Lit' }) +``` + +```dart +final data = await supabase.rpc('search_books_by_title_prefix', params: { 'prefix': 'Lit' }); +``` + +```swift +let response = try await supabase.rpc( + "search_books_by_title_prefix", + params: ["prefix": "Lit"] +) +.execute() +``` + +```kotlin +val rpcParams = mapOf("prefix" to "Lit") +val result = supabase.postgrest.rpc("search_books_by_title_prefix", rpcParams) +``` + +```python +data = client.rpc('search_books_by_title_prefix', { 'prefix': 'Lit' }).execute() +``` + +This function takes a prefix parameter and returns all books where the title contains a word starting with that prefix. The `:*` operator is used to denote a prefix match in the `to_tsquery()` function. + +## Handling spaces in queries + +When you want the search term to include a phrase or multiple words, you can concatenate words using a `+` as a placeholder for space: + +```sql +select * from search_books_by_title_prefix('Little+Puppy'); +``` + +## Web search syntax with `websearch_to_tsquery()` [#websearch-to-tsquery] + +The `websearch_to_tsquery()` function provides an intuitive search syntax similar to popular web search engines, making it ideal for user-facing search interfaces. + +### Basic usage + +```sql +select * +from books +where to_tsvector(description) @@ websearch_to_tsquery('english', 'green eggs'); +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', 'green eggs', { type: 'websearch' }) +``` + +### Quoted phrases + +Use quotes to search for exact phrases: + +```sql +select * from books +where to_tsvector(description || ' ' || title) @@ websearch_to_tsquery('english', '"Green Eggs"'); +-- Matches documents containing "Green" immediately followed by "Eggs" +``` + +### OR searches + +Use "or" (case-insensitive) to search for multiple terms: + +```sql +select * from books +where to_tsvector(description) @@ websearch_to_tsquery('english', 'puppy or rabbit'); +-- Matches documents containing either "puppy" OR "rabbit" +``` + +### Negation + +Use a dash (-) to exclude terms: + +```sql +select * from books +where to_tsvector(description) @@ websearch_to_tsquery('english', 'animal -rabbit'); +-- Matches documents containing "animal" but NOT "rabbit" +``` + +### Complex queries + +Combine multiple operators for sophisticated searches: + +```sql +select * from books +where to_tsvector(description || ' ' || title) @@ + websearch_to_tsquery('english', '"Harry Potter" or "Dr. Seuss" -vegetables'); +-- Matches books by "Harry Potter" or "Dr. Seuss" but excludes those mentioning vegetables +``` + +## Creating indexes + +Now that you have Full Text Search working, create an `index`. This allows Postgres to "build" the documents preemptively so that they +don't need to be created at the time we execute the query. This will make our queries much faster. + +### Searchable columns + +Let's create a new column `fts` inside the `books` table to store the searchable index of the `title` and `description` columns. + +We can use a special feature of Postgres called +[Generated Columns](https://www.postgresql.org/docs/current/ddl-generated-columns.html) +to ensure that the index is updated any time the values in the `title` and `description` columns change. + +```sql +alter table + books +add column + fts tsvector generated always as (to_tsvector('english', description || ' ' || title)) stored; + +create index books_fts on books using gin (fts); -- generate the index + +select id, fts +from books; +``` + +``` +| id | fts | +| --- | --------------------------------------------------------------------------------------------------------------- | +| 1 | 'anim':7 'bigger':6 'littl':10 'poki':9 'puppi':1,11 'slower':3 | +| 2 | 'eat':2 'peter':8 'rabbit':1,9 'tale':6 'veget':4 | +| 3 | 'big':5 'dream':6 'littl':1 'tootl':7 'toy':2 'train':3 | +| 4 | 'chang':3 'color':9 'eat':7 'egg':12 'food':4,10 'green':11 'ham':14 'prefer':5 'sam':1 'unus':8 | +| 5 | 'big':6 'drama':7 'ensu':8 'fire':15 'fourth':1 'goblet':13 'harri':9 'potter':10 'school':4 'start':5 'year':2 | +``` + +### Search using the new column + +Now that we've created and populated our index, we can search it using the same techniques as before: + +```sql +select + * +from + books +where + fts @@ to_tsquery('little & big'); +``` + +```js +const { data, error } = await supabase.from('books').select().textSearch('fts', `'little' & 'big'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('fts', "'little' & 'big'"); +``` + +```swift +let response = try await client + .from("books") + .select() + .textSearch("fts", value: "'little' & 'big'") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("fts", "'title' & 'big'", TextSearchType.NONE) + } +} +``` + +```python +data = client.from_('books').select().text_search('fts', "'little' & 'big'").execute() +``` + +| id | title | author | description | fts | +| --- | ------ | ----------------- | -------------------------------- | ------------------------------------------------------- | +| 3 | Tootle | Gertrude Crampton | Little toy train has big dreams. | 'big':5 'dream':6 'littl':1 'tootl':7 'toy':2 'train':3 | + +## Query operators + +Visit [Postgres: Text Search Functions and Operators](https://www.postgresql.org/docs/current/functions-textsearch.html) +to learn about additional query operators you can use to do more advanced `full text queries`, such as: + +### Proximity: `<->` [#proximity] + +The proximity symbol is useful for searching for terms that are a certain "distance" apart. +For example, to find the phrase `big dreams`, where the a match for "big" is followed immediately by a match for "dreams": + +```sql +select + * +from + books +where + to_tsvector(description) @@ to_tsquery('big <-> dreams'); +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', `'big' <-> 'dreams'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('description', "'big' <-> 'dreams'"); +``` + +```swift +let response = try await client + .from("books") + .select() + .textSearch("description", value: "'big' <-> 'dreams'") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'big' <-> 'dreams'", TextSearchType.NONE) + } +} +``` + +```python +data = client.from_('books').select().text_search('description', "'big' <-> 'dreams'").execute() +``` + +We can also use the `<->` to find words within a certain distance of each other. For example to find `year` and `school` within 2 words of each other: + +```sql +select + * +from + books +where + to_tsvector(description) @@ to_tsquery('year <2> school'); +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', `'year' <2> 'school'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('description', "'year' <2> 'school'"); +``` + +```swift +let response = try await supabase + .from("books") + .select() + .textSearch("description", value: "'year' <2> 'school'") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'year' <2> 'school'", TextSearchType.NONE) + } +} +``` + +```python +data = client.from_('books').select().text_search('description', "'year' <2> 'school'").execute() +``` + +### Negation: `!` [#negation] + +The negation symbol can be used to find phrases which _don't_ contain a search term. +For example, to find records that have the word `big` but not `little`: + +```sql +select + * +from + books +where + to_tsvector(description) @@ to_tsquery('big & !little'); +``` + +```js +const { data, error } = await supabase + .from('books') + .select() + .textSearch('description', `'big' & !'little'`) +``` + +```dart +final result = await client + .from('books') + .select() + .textSearch('description', "'big' & !'little'"); +``` + +```swift +let response = try await client + .from("books") + .select() + .textSearch("description", value: "'big' & !'little'") + .execute() +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'big' & !'little'", TextSearchType.NONE) + } +} +``` + +```python +data = client.from_('books').select().text_search('description', "'big' & !'little'").execute() +``` + +## Ranking search results [#ranking] + +Postgres provides ranking functions to sort search results by relevance, helping you present the most relevant matches first. Since ranking functions need to be computed server-side, use RPC functions and generated columns. + +### Creating a search function with ranking [#search-function-ranking] + +First, create a Postgres function that handles search and ranking: + +```sql +create or replace function search_books(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(to_tsvector('english', books.description), to_tsquery(search_query)) as rank + from books + where to_tsvector('english', books.description) @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +Now you can call this function from your client: + +```js +const { data, error } = await supabase.rpc('search_books', { search_query: 'big' }) +``` + +```dart +final result = await client + .rpc('search_books', params: { 'search_query': 'big' }); +``` + +```python +data = client.rpc('search_books', { 'search_query': 'big' }).execute() +``` + +```sql +select * from search_books('big'); +``` + +### Ranking with weighted columns [#weighted-ranking] + +Postgres allows you to assign different importance levels to different parts of your documents using weight labels. This is especially useful when you want matches in certain fields (like titles) to rank higher than matches in other fields (like descriptions). + +#### Understanding weight labels + +Postgres uses four weight labels: **A**, **B**, **C**, and **D**, where: + +- **A** = Highest importance (weight 1.0) +- **B** = High importance (weight 0.4) +- **C** = Medium importance (weight 0.2) +- **D** = Low importance (weight 0.1) + +#### Creating weighted search columns + +First, create a weighted tsvector column that gives titles higher priority than descriptions: + +```sql +-- Add a weighted fts column +alter table books +add column fts_weighted tsvector +generated always as ( + setweight(to_tsvector('english', title), 'A') || + setweight(to_tsvector('english', description), 'B') +) stored; + +-- Create index for the weighted column +create index books_fts_weighted on books using gin (fts_weighted); +``` + +Now create a search function that uses this weighted column: + +```sql +create or replace function search_books_weighted(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(books.fts_weighted, to_tsquery(search_query)) as rank + from books + where books.fts_weighted @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +#### Custom weight arrays + +You can also specify custom weights by providing a weight array to `ts_rank()`: + +```sql +create or replace function search_books_custom_weights(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank( + '{0.0, 0.2, 0.5, 1.0}'::real[], -- Custom weights {D, C, B, A} + books.fts_weighted, + to_tsquery(search_query) + ) as rank + from books + where books.fts_weighted @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +This example uses custom weights where: + +- A-labeled terms (titles) have maximum weight (1.0) +- B-labeled terms (descriptions) have medium weight (0.5) +- C-labeled terms have low weight (0.2) +- D-labeled terms are ignored (0.0) + +#### Using the weighted search + +```js +// Search with standard weighted ranking +const { data, error } = await supabase.rpc('search_books_weighted', { search_query: 'Harry' }) + +// Search with custom weights +const { data: customData, error: customError } = await supabase.rpc('search_books_custom_weights', { + search_query: 'Harry', +}) +``` + +```python +# Search with standard weighted ranking +data = client.rpc('search_books_weighted', { 'search_query': 'Harry' }).execute() + +# Search with custom weights +custom_data = client.rpc('search_books_custom_weights', { 'search_query': 'Harry' }).execute() +``` + +```sql +-- Standard weighted search +select * from search_books_weighted('Harry'); + +-- Custom weighted search +select * from search_books_custom_weights('Harry'); +``` + +#### Practical example with results + +Say you search for "Harry". With weighted columns: + +1. **"Harry Potter and the Goblet of Fire"** (title match) gets weight A = 1.0 +2. **Books mentioning "Harry" in description** get weight B = 0.4 + +This ensures that books with "Harry" in the title ranks significantly higher than books that only mention "Harry" in the description, providing more relevant search results for users. + +### Using ranking with indexes [#ranking-with-indexes] + +When using the `fts` column you created earlier, ranking becomes more efficient. Create a function that uses the indexed column: + +```sql +create or replace function search_books_fts(search_query text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(books.fts, to_tsquery(search_query)) as rank + from books + where books.fts @@ to_tsquery(search_query) + order by rank desc; +end; +$$ language plpgsql; +``` + +```js +const { data, error } = await supabase.rpc('search_books_fts', { search_query: 'little & big' }) +``` + +```dart +final result = await client + .rpc('search_books_fts', params: { 'search_query': 'little & big' }); +``` + +```python +data = client.rpc('search_books_fts', { 'search_query': 'little & big' }).execute() +``` + +```sql +select * from search_books_fts('little & big'); +``` + +### Using web search syntax with ranking [#websearch-ranking] + +You can also create a function that combines `websearch_to_tsquery()` with ranking for user-friendly search: + +```sql +create or replace function websearch_books(search_text text) +returns table(id int, title text, description text, rank real) as $$ +begin + return query + select + books.id, + books.title, + books.description, + ts_rank(books.fts, websearch_to_tsquery('english', search_text)) as rank + from books + where books.fts @@ websearch_to_tsquery('english', search_text) + order by rank desc; +end; +$$ language plpgsql; +``` + +```js +// Support natural search syntax +const { data, error } = await supabase.rpc('websearch_books', { + search_text: '"little puppy" or train -vegetables', +}) +``` + +```sql +select * from websearch_books('"little puppy" or train -vegetables'); +``` + +## Resources + +- [Postgres: Text Search Functions and Operators](https://www.postgresql.org/docs/12/functions-textsearch.html) diff --git a/packages/ui/docs/security.md b/packages/ui/docs/security.md index 09f13910..444416c2 100644 --- a/packages/ui/docs/security.md +++ b/packages/ui/docs/security.md @@ -1,41 +1,375 @@ +# Security Architecture โ€” Polymech -# Security Improvement Plan - -## 1. Secrets Management (Critical) -**Issue**: [`Profile.tsx`](../src/pages/Profile.tsx) currently fetches and exposes API keys (OpenAI, Google, etc.) to the client browser. -**Goal**: Never send raw API keys to the client unless explicitly requested for a "Settings" view context, and even then, mask them. - -### A. Client Side (`Profile.tsx`) -- [ ] **Remove** all code that fetches `_api_key` columns from `profiles` in [`Profile.tsx`](../src/pages/Profile.tsx). -- [ ] **Remove** Input fields for API keys in the main Profile view in [`Profile.tsx`](../src/pages/Profile.tsx). -- [ ] **Create** a new "Provider Settings" page (or modal) protected by re-auth or strict checks (Target: `src/pages/ProviderSettings.tsx`). -- [ ] Use `/api/me/secrets` (Server Proxy) to manage keys, never direct DB Updates for secrets. - -### B. Server Side -- [ ] Ensure `user_secrets` table has RLS `auth.uid() = user_id`. -- [ ] Ensure no public endpoints (like `/api/profile/:id`) return columns from `user_secrets` in [`ServingProduct`](../server/src/products/serving/index.ts). +Polymech implements a layered security model that covers authentication, authorization, threat mitigation, and observability. Every layer is configurable via environment variables and manageable through admin APIs. --- -## 2. Authorization & ACL -**Goal**: Secure multi-user collaboration and Organization access. +## Table of Contents -### A. Shared Pages -- [ ] Implement `page_collaborators` RLS. -- [ ] **Verify**: A user cannot edit a page they are only a 'viewer' on. -- [ ] **Verify**: Listing pages returns both owned and shared pages in [`PageManager.tsx`](../src/components/PageManager.tsx). - -### B. Organization Impersonation -- [ ] **Middleware**: Implement `OrganizationMiddleware` in `server/src`. -- [ ] **Logic**: If `X-Org-Slug` header is present: - 1. Check if `auth.uid()` is an Admin/Member of that Org. - 2. If yes, scope all queries to that Organization's `collection_id` or Context. - 3. (Advanced) Allow "Impersonation" where an Admin acts as a specific user. This requires a signed Token exchange or a Server-Side "Sudo" mode. **Decision**: For now, stick to Role-Based Access (Admin reads all Org data) rather than direct User Impersonation to avoid audit confusion. +- [Authentication](#authentication) +- [Authorization & Access Control](#authorization--access-control) +- [Threat Mitigation](#threat-mitigation) +- [Transport Security](#transport-security) +- [Observability & Auditing](#observability--auditing) +- [Admin API](#admin-api) +- [Configuration Reference](#configuration-reference) --- -## 3. General Hardening -- [ ] **404 Handling**: In [`Post.tsx`](../src/pages/Post.tsx), ensure 404s do not redirect blindly. - - [ ] If permission denied (Private post), show "Unauthorized" (403). - - [ ] If missing, show "Not Found" (404). -- [ ] **Rate Limiting**: Ensure `/api/feed` and `/api/search` have basic rate limiting (using `hono-rate-limiter` or Redis) to prevent scraping. +## Authentication + +### JWT Bearer Tokens + +All authenticated requests use Supabase-issued JWTs via the `Authorization: Bearer ` header. The server validates tokens through Supabase's `auth.getUser()`, with results cached in-memory to avoid repeated round-trips. + +### Three Authentication Modes + +The server provides three middleware layers that can be composed per-route: + +| Middleware | Behavior | +|-----------|----------| +| **`authMiddleware`** | **Strict** โ€” rejects any request without a valid Bearer token. Returns `401` immediately. | +| **`optionalAuthMiddleware`** | **Flexible** โ€” resolves the user if a token is present, but allows unauthenticated access to public endpoints. Respects `REQUIRE_AUTH` env var for non-public routes. Also supports token via `?token=` query param (for SSE streams). | +| **`adminMiddleware`** | **Role-based** โ€” checks `user_roles` table for `role = 'admin'`. Returns `403 Forbidden` if the user lacks admin privileges. Only applies to routes registered in `AdminEndpointRegistry`. | + +### Request Flow + +``` +Request โ†’ CORS โ†’ Blocklist โ†’ Auto-Ban โ†’ Analytics โ†’ optionalAuthMiddleware โ†’ adminMiddleware โ†’ Rate Limiter โ†’ Body Limit โ†’ Route Handler +``` + +1. **CORS** validates origin against env-driven allowlist +2. **Blocklist** checks manual blocklist (`config/blocklist.json`) +3. **Auto-Ban** checks automatic ban list (`config/ban.json`) +4. **Analytics** logs the request (non-blocking) +5. **Optional Auth** resolves user identity if token present; validates JWT `exp` claim and caches for 30s +6. **Admin Check** enforces admin-only on registered admin routes +7. **Rate Limiter** enforces `RATE_LIMIT_MAX` requests per `RATE_LIMIT_WINDOW_MS` per IP/user +8. **Body Limit** enforces `MAX_UPLOAD_SIZE` (default 10MB) on all API requests +9. **Route Handler** executes with `c.get('userId')`, `c.get('user')`, and `c.get('isAdmin')` available + +--- + +## Authorization & Access Control + +### Route-Level Access Control + +Routes are classified at definition time using decorators: + +```typescript +// In route definitions: +Public(route) // Registers in PublicEndpointRegistry โ†’ no auth required +Admin(route) // Registers in AdminEndpointRegistry โ†’ admin role required +``` + +The `PublicEndpointRegistry` and `AdminEndpointRegistry` use pattern matching (supporting `:param` and `{param}` styles) to determine access at runtime. This means authorization is declarative โ€” defined alongside the route, not scattered across middleware. + +### Public Endpoints + +All SEO and content delivery routes are public by default: + +- `/feed.xml`, `/products.xml`, `/sitemap-en.xml`, `/llms.txt` +- `/post/:id.xhtml`, `/post/:id.pdf`, `/post/:id.md`, `/post/:id.json` +- `/user/:id/pages/:slug.xhtml`, `.html`, `.pdf`, `.md`, `.json`, `.email.html` +- `/api/posts/:id`, `/api/feed`, `/api/profiles`, `/api/media-items` +- `/embed/:id`, `/embed/page/:id` + +### Admin-Only Endpoints + +Privileged operations require both authentication and the `admin` role: + +| Endpoint | Description | +|----------|-------------| +| `POST /api/admin/system/restart` | Graceful server restart | +| `GET /api/admin/bans` | View current ban list | +| `POST /api/admin/bans/unban-ip` | Remove an IP ban | +| `POST /api/admin/bans/unban-user` | Remove a user ban | +| `GET /api/admin/bans/violations` | View violation statistics | +| `POST /api/flush-cache` | Flush all server caches | +| `GET /api/analytics` | View request analytics | +| `DELETE /api/analytics` | Clear analytics data | +| `GET /api/analytics/stream` | Live analytics stream (SSE) | + +### VFS (Virtual File System) ACL + +The Storage product implements a full ACL system for its virtual file system: + +- **Mounts** โ€” isolated storage namespaces with per-mount access control +- **Grants** โ€” explicit read/write permissions per user per mount +- **Revocations** โ€” ability to revoke access without deleting the mount +- **Glob-based queries** โ€” file listing supports `glob` patterns, scoped to authorized mounts + +### Supabase RLS + +Database-level security is enforced through PostgreSQL Row-Level Security: + +- `user_roles` โ€” scoped by `auth.uid() = user_id` +- `user_secrets` โ€” API keys never exposed through public endpoints; accessed via `/api/me/secrets` proxy with masked GET and server-proxied PUT +- Content tables โ€” owner-based access with collaboration extensions + +### Secrets Management + +API keys (OpenAI, Google, etc.) are stored in `user_secrets` and never returned in cleartext from any endpoint. The `/api/me/secrets` proxy returns masked values (last 4 characters only) with a `has_key` boolean indicator. Client code never accesses `user_secrets` directly. + +### CSRF Protection + +Bearer token auth via `Authorization` header is inherently CSRF-proof โ€” browsers cannot attach custom headers in cross-origin form submissions. No CSRF tokens are needed. + +--- + +## Threat Mitigation + +### Blocklist (Manual) + +The `blocklist.json` file in `/config/` provides static blocking of known bad actors: + +```json +{ + "blockedIPs": ["203.0.113.50"], + "blockedUserIds": ["malicious-user-uuid"], + "blockedTokens": ["compromised-jwt-token"] +} +``` + +The blocklist is loaded on startup and checked for every API request. Blocked entities receive `403 Forbidden`. + +### Auto-Ban (Automatic) + +The auto-ban system tracks violations in-memory and automatically bans entities that exceed configurable thresholds: + +**How it works:** + +1. Rate limit violations are recorded per IP or user key +2. When violations exceed `AUTO_BAN_THRESHOLD` (default: 5) within `AUTO_BAN_WINDOW_MS` (default: 10s), the entity is permanently banned +3. Bans are persisted to `config/ban.json` and survive server restarts +4. Old violation records are cleaned up periodically (`AUTO_BAN_CLEANUP_INTERVAL_MS`) + +**What gets tracked:** + +- Repeated rate limit violations +- Repeated auth failures +- Suspicious request patterns + +**Ban types:** + +| Type | Scope | +|------|-------| +| IP ban | Blocks all requests from the IP | +| User ban | Blocks all requests from the user ID | +| Token ban | Blocks requests with a specific JWT | + +### Rate Limiting + +Rate limiting uses `hono-rate-limiter` with configurable windows and limits: + +- **Global API limiter** โ€” `RATE_LIMIT_MAX` requests per `RATE_LIMIT_WINDOW_MS` (applied to `/api/*`) +- **Custom per-endpoint limiters** โ€” `createCustomRateLimiter(limit, windowMs)` for endpoints needing different thresholds +- **Key generation** โ€” rate limits are tracked per authenticated user (if token present) or per IP (fallback) +- **Standard headers** โ€” responses include `RateLimit-*` headers (draft-6 spec) +- **Violation escalation** โ€” rate limit violations are forwarded to the auto-ban system + +--- + +## Transport Security + +### Secure Headers + +Applied globally via Hono's `secureHeaders` middleware: + +| Header | Value | Rationale | +|--------|-------|-----------| +| **Strict-Transport-Security** | `max-age=31536000; includeSubDomains` | 1-year HSTS, enforces HTTPS | +| **X-Frame-Options** | `SAMEORIGIN` | Clickjacking protection (relaxed for `/embed/*` routes) | +| **Referrer-Policy** | `strict-origin-when-cross-origin` | Preserves analytics referrer data same-origin, protects privacy cross-origin | +| **Permissions-Policy** | `camera=(), microphone=(), geolocation=(), payment=(self)` | Restricts unused browser features; payment allowed for Stripe | +| **Content-Security-Policy** | See below | Full directive set protecting against XSS | +| **Cross-Origin-Resource-Policy** | Disabled | Media assets served cross-origin | +| **Cross-Origin-Embedder-Policy** | Disabled | Compatibility with external image/video sources | +| **Cross-Origin-Opener-Policy** | Disabled | No popup isolation needed | + +#### Embed Route Override + +Routes under `/embed/*` strip `X-Frame-Options` and widen `frame-ancestors` to `*`, allowing external sites to iframe embed widgets while keeping all other routes protected against clickjacking. + +#### CSP Directives + +| Directive | Value | Rationale | +|-----------|-------|-----------| +| `default-src` | `'self'` | Baseline deny-all | +| `script-src` | `'self' 'nonce-' cdn.jsdelivr.net` | Nonce-based inline script execution + Scalar UI | +| `style-src` | `'self' 'unsafe-inline' fonts.googleapis.com cdn.jsdelivr.net` | Google Fonts CSS + Scalar UI (`unsafe-inline` required for dynamic styles) | +| `font-src` | `'self' fonts.gstatic.com cdn.jsdelivr.net fonts.scalar.com` | Google Fonts + Scalar fonts | +| `img-src` | `'self' data: blob: *.supabase.co *.polymech.info` | Supabase Storage + CDN assets | +| `connect-src` | `'self' *.supabase.co wss://*.supabase.co api.openai.com assets.polymech.info cdn.jsdelivr.net proxy.scalar.com` | API, Realtime, AI, Scalar | +| `media-src` | `'self' blob: *.supabase.co assets.polymech.info stream.mux.com` | Video/audio sources | +| `frame-src` | `'self' *.supabase.co` | Supabase Auth popup | +| `frame-ancestors` | `'self'` | Default: same-origin only (relaxed to `*` for `/embed/*`) | +| `object-src` | `'none'` | Block Flash/Java | +| `base-uri` | `'self'` | Prevent base-tag hijacking | + +### Compression + +All responses are compressed with Brotli/gzip via `hono/compress`, reducing payload sizes and improving TTFB. + +### CORS + +CORS origin validation is driven by `CORS_ORIGINS` env var: + +``` +# Production โ€” only listed origins get Access-Control-Allow-Origin +CORS_ORIGINS=https://service.polymech.info,https://polymech.info,https://forum.polymech.info + +# Development (unset / default) โ€” falls back to origin: '*' +``` + +| Setting | Production | Development | +|---------|-----------|-------------| +| **Origin** | Env-driven allowlist | `*` | +| **Methods** | GET, POST, PUT, DELETE, PATCH, OPTIONS | Same | +| **Credentials** | `true` | `false` (browsers disallow `credentials: true` with `*`) | +| **Max Preflight Cache** | 600s (10 min) | Same | + +Custom headers are whitelisted for client SDK compatibility (Stainless, etc.). + +--- + +## Observability & Auditing + +### Security Logging + +All security events are logged via a dedicated `securityLogger` (Pino) with structured context: + +- Auth failures with IP + user agent +- Admin actions with acting user ID +- Ban/unban events with target and outcome +- Rate limit violations with key and threshold + +### Analytics Middleware + +Every request (except static assets, doc UIs, and widget paths) is tracked: + +| Field | Source | +|-------|--------| +| Method + Path | Request | +| IP Address | Hardened extraction via `getClientIpFromHono()` โ€” validates `socket.remoteAddress` against trusted proxy ranges before trusting `X-Forwarded-For` | +| User Agent | Request header | +| Session ID | `pm_sid` cookie (30-minute sliding expiry) | +| Geo Location | Background async lookup via BigDataCloud API | +| User ID | Resolved from JWT if present | +| Response Time | Measured end-to-end | +| Status Code | Response | + +**Geo-lookup resilience:** + +- Results cached in-memory + disk (`cache/geoip.json`) +- Non-blocking โ€” resolved in background after response is sent +- Circuit breaker โ€” after 3 consecutive failures, geo lookups are disabled for 30 seconds +- Timeout โ€” individual lookups are capped at 2 seconds +- De-duplication โ€” concurrent lookups for the same IP share a single request + +### Real-Time Streams + +Security events and analytics are available as live Server-Sent Event (SSE) streams: + +``` +GET /api/logs/system/stream โ†’ Live system + security logs +GET /api/analytics/stream โ†’ Live request analytics +``` + +--- + +## Admin API + +All admin endpoints require authentication + admin role. Documented in OpenAPI and accessible via Swagger UI / Scalar. + +### Ban Management + +``` +GET /api/admin/bans โ†’ View all banned IPs, users, tokens +POST /api/admin/bans/unban-ip โ†’ { "ip": "203.0.113.50" } +POST /api/admin/bans/unban-user โ†’ { "userId": "user-uuid" } +GET /api/admin/bans/violations โ†’ View current violation tracking stats +``` + +### System Operations + +``` +POST /api/admin/system/restart โ†’ Graceful restart (systemd re-spawns) +POST /api/flush-cache โ†’ Flush all in-memory + disk caches +POST /api/cache/invalidate โ†’ Selective cache invalidation by path/type +GET /api/cache/inspect โ†’ View cache state, TTLs, dependency graph +``` + +### Analytics + +``` +GET /api/analytics โ†’ Historical request data +GET /api/analytics/stream โ†’ Real-time SSE stream +DELETE /api/analytics โ†’ Clear analytics data +``` + +--- + +## Configuration Reference + +All security settings are configurable via environment variables: + +### Authentication + +| Variable | Default | Description | +|----------|---------|-------------| +| `REQUIRE_AUTH` | `false` | When `true`, all non-public API routes require authentication | +| `CORS_ORIGINS` | `*` | Comma-separated CORS allowed origins. Falls back to `*` if unset | + +### Rate Limiting + +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_MAX` | `1` | Max requests per window | +| `RATE_LIMIT_WINDOW_MS` | `50` | Window duration in milliseconds | + +### Auto-Ban + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTO_BAN_THRESHOLD` | `5` | Violations before auto-ban | +| `AUTO_BAN_WINDOW_MS` | `10000` | Violation counting window (ms) | +| `AUTO_BAN_CLEANUP_INTERVAL_MS` | `60000` | How often to clean up old violation records | + +### API Documentation + +| Variable | Default | Description | +|----------|---------|-------------| +| `SCALAR_AUTH_TOKEN` | `''` | Pre-filled Bearer token for Scalar UI | +| `NODE_ENV` | โ€” | When `production`, Swagger/Scalar UIs are disabled | + +### Files + +| File | Description | +|------|-------------| +| `config/blocklist.json` | Manual IP/user/token blocklist | +| `config/ban.json` | Auto-generated ban list (persisted auto-bans) | +| `cache/geoip.json` | Geo-IP lookup cache | + +--- + +## TODO โ€” Pending Improvements + +### High Priority + +- [ ] **Swagger/Scalar in production** โ€” Currently disabled entirely in production. Consider enabling at a protected `/admin/reference` path behind admin auth for debugging +- [-] **Audit logging** โ€” Admin actions (unban, restart, cache flush) log to Pino but should also persist to a dedicated `audit_log` table in the database + +### Medium Priority + +- [ ] **Page collaboration ACL** โ€” Implement `page_collaborators` RLS so viewers cannot edit shared pages +- [ ] **Organization impersonation** โ€” Add `X-Org-Slug` header middleware to scope queries to organization context with role-based access (Admin reads all, Member reads own) +- [ ] **Per-route rate limiting** โ€” Apply stricter limits to expensive endpoints (`/api/search`, `/api/serving/site-info`, image optimization proxy) using `createCustomRateLimiter` +- [ ] **Redis-backed rate limiting** โ€” Current rate limiter is in-memory (per-instance). For multi-instance deploys, switch to a Redis-backed store via `hono-rate-limiter` + +### Low Priority / Nice-to-Have + +- [ ] **API key authentication** โ€” Support `X-API-Key` header as an alternative to Bearer tokens for third-party integrations +- [ ] **Webhook signature verification** โ€” For incoming webhooks (Stripe, etc.), verify HMAC signatures before processing +- [ ] **Geo-blocking** โ€” Extend blocklist to support country-level blocking using the existing geo-IP cache +- [ ] **Security headers audit** โ€” Run [securityheaders.com](https://securityheaders.com) and [Mozilla Observatory](https://observatory.mozilla.org/) checks on production and address any findings diff --git a/packages/ui/docs/seo.md b/packages/ui/docs/seo.md new file mode 100644 index 00000000..e774cfb3 --- /dev/null +++ b/packages/ui/docs/seo.md @@ -0,0 +1,675 @@ +# SEO & Discoverability on Polymech + +Polymech is built as an SEO-first platform. Every piece of content โ€” whether it's a media post, a CMS page, or a product listing โ€” is automatically discoverable by search engines, social platforms, AI agents, and feed readers. No plugins, no external services, no config files. It's all baked in. + +This document covers every SEO-related feature the platform offers. + +--- + +## Table of Contents + +- [Multi-Format Content Export](#multi-format-content-export) +- [Discovery Endpoints](#discovery-endpoints) +- [Open Graph & Social Meta](#open-graph--social-meta) +- [JSON-LD Structured Data](#json-ld-structured-data) +- [Server-Side Rendering & Initial State Injection](#server-side-rendering--initial-state-injection) +- [Responsive Image Optimization](#responsive-image-optimization) +- [Internationalization (i18n)](#internationalization-i18n) +- [Embeddable Content](#embeddable-content) +- [API-First Architecture](#api-first-architecture) +- [Developer Experience](#developer-experience) +- [Client-Side SEO & Performance](#client-side-seo--performance) +- [Route Reference](#route-reference) + +--- + +## Multi-Format Content Export + +Every content entity on Polymech (posts and pages) can be exported in multiple formats by simply changing the file extension in the URL. No API keys, no special headers โ€” just append the extension. + +> **Source:** Page exports โ†’ [pages-routes.ts](../server/src/products/serving/pages/pages-routes.ts), Post exports โ†’ [db-post-exports.ts](../server/src/products/serving/db/db-post-exports.ts) + +### Pages + +Pages are rich, widget-based documents built with a visual editor. They export to: + +> [pages-rich-html.ts](../server/src/products/serving/pages/pages-rich-html.ts) ยท [pages-html.ts](../server/src/products/serving/pages/pages-html.ts) ยท [pages-pdf.ts](../server/src/products/serving/pages/pages-pdf.ts) ยท [pages-markdown.ts](../server/src/products/serving/pages/pages-markdown.ts) ยท [pages-email.ts](../server/src/products/serving/pages/pages-email.ts) ยท [pages-data.ts](../server/src/products/serving/pages/pages-data.ts) + +| Format | URL Pattern | Content-Type | Description | +|--------|-------------|--------------|-------------| +| **XHTML** | `/user/:id/pages/:slug.xhtml` | `text/html` | Standalone rich HTML with Tailwind CSS styling, full meta tags, JSON-LD, and responsive layout. Ready to share or archive. | +| **HTML** | `/user/:id/pages/:slug.html` | `text/html` | SPA shell with injected Open Graph metadata for crawlers and social previews. | +| **PDF** | `/user/:id/pages/:slug.pdf` | `application/pdf` | Print-ready PDF export. Great for invoices, reports, or offline sharing. | +| **Markdown** | `/user/:id/pages/:slug.md` | `text/markdown` | Clean Markdown export of the page content. Useful for migration, backups, or feeding to other systems. | +| **JSON** | `/user/:id/pages/:slug.json` | `application/json` | Raw page data including content tree, metadata, and author profile. Perfect for headless CMS integrations. | +| **Email HTML** | `/user/:id/pages/:slug.email.html` | `text/html` | Email-client-optimized HTML with inlined styles and table-based layout. Compatible with Outlook, Gmail, Apple Mail, and others. | + +### Posts + +Posts are media-centric entries (photos, videos, link cards). They export to: + +> [db-post-exports.ts](../server/src/products/serving/db/db-post-exports.ts) ยท [db-posts.ts](../server/src/products/serving/db/db-posts.ts) + +| Format | URL Pattern | Content-Type | Description | +|--------|-------------|--------------|-------------| +| **XHTML** | `/post/:id.xhtml` | `text/html` | Standalone rich HTML with Tailwind CSS, responsive image gallery, OG meta, and JSON-LD structured data. | +| **PDF** | `/post/:id.pdf` | `application/pdf` | PDF export of the post with embedded images. | +| **Markdown** | `/post/:id.md` | `text/markdown` | Markdown with title, description, and linked images. | +| **JSON** | `/post/:id.json` | `application/json` | Full post data with pictures array and author profile. | + +### How it works + +The export system doesn't use templates or pre-rendered files. Each format is generated server-side on-the-fly from the same canonical content tree, which means: + +- Exports are always up-to-date โ€” no build step needed +- All formats share the same data pipeline โ€” update once, export everywhere +- The widget-based content system is format-agnostic โ€” markdown text, photo cards, galleries, tabs, and nested layouts all render correctly in every format + +--- + +## Discovery Endpoints + +> **Source:** [content.ts](../server/src/products/serving/content.ts) ยท [routes.ts](../server/src/products/serving/routes.ts) + +### RSS Feed โ€” `/feed.xml` + +Standard RSS 2.0 feed of the latest posts and pages. Supports filtering by category via query parameters: โ†’ [content.ts](../server/src/products/serving/content.ts) `handleGetFeedXml` + +``` +/feed.xml?categorySlugs=tutorials&limit=50&sortBy=latest +``` + +- Image enclosures with optimized proxy URLs +- Per-item author attribution +- Category filtering (by ID or slug, including descendants) +- Configurable sort order (`latest` or `top`) + +### Google Merchant Feed โ€” `/products.xml` + +A Google Merchant Center compatible XML feed for products. Automatically includes only items with pricing data set through the type system: โ†’ [content.ts](../server/src/products/serving/content.ts) `handleGetMerchantFeed` + +```xml +product-uuid +Product Name +29.99 EUR +Category > Subcategory +https://service.polymech.info/api/images/cache/optimized.jpg +``` + +- Automatically resolves price, currency, and condition from the type system & page variables +- Full category path hierarchy +- Optimized product images via the image proxy +- All items link to their canonical page/post URL + +### Sitemap โ€” `/sitemap-en.xml` + +Auto-generated XML sitemap of all public, visible pages: โ†’ [content.ts](../server/src/products/serving/content.ts) `handleGetSitemap` + +```xml + + https://polymech.info/user/username/pages/my-page + 2025-03-01T12:00:00.000Z + weekly + 0.8 + +``` + +- Only includes public + visible pages (respects content visibility settings) +- Uses `updated_at` for accurate `` timestamps +- Ready to submit to Google Search Console, Bing Webmaster Tools, etc. + +### LLM-Readable Content โ€” `/llms.txt` & `/llms.md` + +Following the emerging [llms.txt standard](https://llmstxt.org/), Polymech generates a machine-readable summary of the entire site at `/llms.txt` (and `/llms.md` for Markdown content-type): โ†’ [content.ts](../server/src/products/serving/content.ts) `handleGetLLMText` + +```markdown +# Polymech + +> A full-stack media platform... + +## Pages + +- [Getting Started](https://polymech.info/user/admin/pages/getting-started): Introduction to... +- [Product Catalog](https://polymech.info/user/admin/pages/catalog): Browse our... + +## Posts + +- [New Release](https://polymech.info/post/abc123) by admin: Announcing... + +## Public API + +- Post Details JSON: /api/posts/{id} +- Page XHTML Export: /user/{username}/pages/{slug}.xhtml +- RSS Feed: /feed.xml +- Sitemap: /sitemap-en.xml +``` + +This endpoint is designed for AI agents (ChatGPT, Claude, Perplexity, etc.) to quickly understand what the site contains and how to access it. It includes: + +- Site description from `app-config.json` +- Top 20 public pages with links and descriptions +- Top 20 recent posts with author attribution +- Full public API reference with URL patterns + +### OpenAPI / Scalar API Reference โ€” `/api/reference` + +Every API endpoint is documented via OpenAPI 3.0 and served through a Scalar interactive UI. This isn't just documentation โ€” it's a live, testable interface for every route in the system. + +--- + +## Open Graph & Social Meta + +Every content URL automatically injects proper Open Graph and Twitter Card metadata into the HTML ``. This happens at the server level before the SPA loads, so crawlers and social platforms always get the right preview. + +> **Source:** SPA injection โ†’ [renderer.ts](../server/src/products/serving/renderer.ts), Posts โ†’ [db-post-exports.ts](../server/src/products/serving/db/db-post-exports.ts), Pages XHTML โ†’ [pages-rich-html.ts](../server/src/products/serving/pages/pages-rich-html.ts), Pages HTML โ†’ [pages-html.ts](../server/src/products/serving/pages/pages-html.ts) + +### What gets injected + +| Meta Tag | Source | +|----------|--------| +| `og:title` | Page title or post title with author attribution | +| `og:description` | Page description, extracted from content, or auto-generated fallback | +| `og:image` | First photo card, gallery image, or markdown image โ€” resolved through the image optimization proxy | +| `og:type` | `article` for pages/posts, `product` for product pages | +| `og:url` | Canonical URL | +| `twitter:card` | `summary_large_image` (when image is available) | +| `twitter:title` | Same as `og:title` | +| `twitter:image` | Same as `og:image` | + +### Image resolution priority + +The system walks the content tree to find the best display image: + +1. **Photo Card widget** โ€” highest priority, uses picture ID for resolution +2. **Gallery widget** โ€” uses first image from the gallery +3. **Explicit image widget** โ€” direct image URL +4. **Markdown image** โ€” extracted from inline markdown `![](url)` +5. **Page meta thumbnail** โ€” fallback from page metadata + +All images are proxied through the image optimization service (see below) to ensure optimal dimensions and format for social previews. + +### Home Page + +The home page (`/`) gets its own meta injection using site config from `app-config.json`, with optional override from the `_site/home` system page. This includes full JSON-LD with `WebSite` and `Organization` schemas, plus a `SearchAction` for sitelinks search box. + +--- + +## JSON-LD Structured Data + +Polymech generates context-appropriate JSON-LD structured data for every content type: + +### Posts โ†’ `SocialMediaPosting` + +```json +{ + "@context": "https://schema.org", + "@type": "SocialMediaPosting", + "headline": "Post Title", + "image": ["https://...optimized.jpg"], + "datePublished": "2025-03-01T12:00:00Z", + "author": { + "@type": "Person", + "name": "Author Name" + } +} +``` + +### Pages โ†’ `Article` + +```json +{ + "@context": "https://schema.org", + "@type": "Article", + "headline": "Page Title by Author | PolyMech", + "author": { "@type": "Person", "name": "Author" }, + "description": "...", + "image": "https://..." +} +``` + +### Product Pages โ†’ `Product` with `Offer` + +When a page belongs to a `products` category, the structured data automatically switches to the `Product` schema with pricing: + +```json +{ + "@context": "https://schema.org", + "@type": "Product", + "name": "Product Name", + "description": "...", + "image": "https://...", + "category": "Products > Subcategory", + "offers": { + "@type": "Offer", + "price": "29.99", + "priceCurrency": "EUR", + "availability": "https://schema.org/InStock", + "itemCondition": "https://schema.org/NewCondition" + } +} +``` + +Price, currency, condition, and availability are resolved from the type system / page variables โ€” no manual JSON-LD editing needed. + +### Home Page โ†’ `WebSite` + `Organization` + +```json +{ + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebSite", + "name": "PolyMech", + "url": "https://polymech.info", + "potentialAction": { + "@type": "SearchAction", + "target": "https://polymech.info/search?q={search_term_string}", + "query-input": "required name=search_term_string" + } + }, + { + "@type": "Organization", + "name": "Polymech", + "url": "https://polymech.info", + "logo": "https://..." + } + ] +} +``` + +--- + +## Server-Side Rendering & Initial State Injection + +Polymech is a React SPA, but it doesn't sacrifice SEO for interactivity. The server pre-fetches data and injects it into the HTML before sending it to the client: + +> **Source:** Home/post/embed injection โ†’ [index.ts](../server/src/products/serving/index.ts), Embed pages โ†’ [content.ts](../server/src/products/serving/content.ts), Profile injection โ†’ [db-user.ts](../server/src/products/serving/db/db-user.ts) + +- **Home page** (`/`): Feed data and site home page content are fetched in parallel and injected as `window.__INITIAL_STATE__` +- **Post pages** (`/post/:id`): Post metadata is resolved and injected as OG/Twitter/JSON-LD meta tags +- **User pages** (`/user/:id/pages/:slug`): Page content, author profile, category paths, and meta image are all resolved server-side + +This means: + +- **Google** sees a fully populated `` with title, description, image, and structured data +- **Social platforms** (Facebook, Twitter, LinkedIn, Discord, Slack) render rich link previews immediately +- **The React app** hydrates instantly without a loading spinner โ€” the data is already there + +--- + +## Responsive Image Optimization + +Every image served through Polymech's SEO routes is automatically optimized: + +> **Source:** [db-pictures.ts](../server/src/products/serving/db/db-pictures.ts) ยท [html-generator.ts](../server/src/products/serving/pages/html-generator.ts) + +- **Format negotiation**: Images are served in modern formats (AVIF, WebP) with JPEG fallback +- **Responsive srcsets**: Multiple size variants (320w, 640w, 1024w) are pre-generated and cached on disk +- **Aspect-ratio preservation**: Height is calculated from source metadata to prevent layout shift +- **LCP optimization**: The first image in any export gets `fetchpriority="high"`, subsequent images get `loading="lazy"` +- **Edge caching**: Optimized variants are served from `/api/images/cache/` after first generation + +The XHTML exports use `` tags with proper `loading` and `fetchpriority` attributes. The RSS and Merchant feeds use the image proxy URLs for optimized product images at 1200px width. + +--- + +## Internationalization (i18n) + +Polymech's SEO features are fully i18n-aware, all the way down to the widget level. + +> **Source:** [pages-i18n.ts](../server/src/products/serving/pages/pages-i18n.ts) ยท [db-i18n.ts](../server/src/products/serving/db/db-i18n.ts) + +### How it works + +1. **Widget-level translations** โ€” Each widget in a page (markdown text, photo cards, tabs, etc.) can have its content translated to any language. Translations are stored per `widget_id` + `prop_path` + `target_lang`. + +2. **Page meta translations** โ€” Title and description can be translated using a special `__meta__` sentinel in the translations table. + +3. **Feed translations** โ€” The home feed widget in XHTML exports translates page titles and descriptions when a `?lang=xx` parameter is provided. + +### Where i18n applies + +| Feature | i18n Support | +|---------|-------------| +| XHTML page export | โœ… `?lang=de` translates all widget content, title, and description | +| XHTML rich HTML export | โœ… Feed items within home widgets are translated | +| HTML meta injection | โœ… Translated title/description used for OG tags | +| Markdown export | โœ… Widget content translated before Markdown conversion | +| Email export | โœ… Full widget translation applied before email rendering | +| RSS feed | Pages in feed use translated descriptions | +| Sitemap | URLs point to canonical (untranslated) versions | +| llms.txt | Currently English only (descriptions from source content) | + +### Usage + +Append `?lang=xx` to any page export URL: + +``` +/user/admin/pages/about.xhtml?lang=de โ†’ German rich HTML +/user/admin/pages/about.md?lang=fr โ†’ French Markdown +/user/admin/pages/about.email.html?lang=es โ†’ Spanish email +``` + +Translation management is handled through the platform's built-in glossary system and widget translation API, with AI-assisted translation support. + +--- + +## Embeddable Content + +Posts and pages can be embedded in external sites via iframe using the embed routes: โ†’ [content.ts](../server/src/products/serving/content.ts) + +``` +/embed/:postId โ†’ Embeddable post viewer +/embed/page/:pageId โ†’ Embeddable page viewer +``` + +Embed pages are served with injected initial state (no API call needed on load) and include proper meta for social previews when the embed URL itself is shared. + +--- + +## API-First Architecture + +All SEO endpoints are part of the OpenAPI 3.0 spec and documented at `/api/reference`. This means: + +> **Source:** Route definitions โ†’ [routes.ts](../server/src/products/serving/routes.ts), Product registration โ†’ [index.ts](../server/src/products/serving/index.ts) + +- Every route has proper request/response schemas +- Rate limiting and caching headers are standardized +- Third-party tools (Zapier, n8n, custom scripts) can programmatically access all content +- The API is browsable and testable through the interactive Scalar UI + +### Relevant data endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /api/posts/:id` | Full post data with pictures, responsive variants, and video job status | +| `GET /api/user-page/:identifier/:slug` | Full page data with content tree, profile, and metadata | +| `GET /api/feed` | Paginated feed with category filtering, sorting, and user-specific likes | +| `GET /api/profiles?ids=...` | Batch user profile lookup | +| `GET /api/media-items?ids=...` | Batch media item lookup with responsive image generation | +| `GET /api/serving/site-info?url=...` | Extract OG/JSON-LD metadata from any external URL โ†’ [site-info.ts](../server/src/products/serving/site-info.ts) | +| `GET /api/search?q=...` | Full-text search across posts and pages โ†’ [db-search.ts](../server/src/products/serving/db/db-search.ts) | + +--- + +## Route Reference + +### Content Exports + +| Route | Method | Description | +|-------|--------|-------------| +| `/post/:id.xhtml` | GET | Post as standalone rich HTML | +| `/post/:id.pdf` | GET | Post as PDF | +| `/post/:id.md` | GET | Post as Markdown | +| `/post/:id.json` | GET | Post as JSON | +| `/user/:id/pages/:slug.xhtml` | GET | Page as standalone rich HTML | +| `/user/:id/pages/:slug.html` | GET | Page with OG meta injection | +| `/user/:id/pages/:slug.pdf` | GET | Page as PDF | +| `/user/:id/pages/:slug.md` | GET | Page as Markdown | +| `/user/:id/pages/:slug.json` | GET | Page as JSON | +| `/user/:id/pages/:slug.email.html` | GET | Page as email-optimized HTML | + +### Discovery & Feeds + +| Route | Method | Description | +|-------|--------|-------------| +| `/feed.xml` | GET | RSS 2.0 feed | +| `/products.xml` | GET | Google Merchant XML feed | +| `/sitemap-en.xml` | GET | XML Sitemap | +| `/llms.txt` | GET | LLM-readable site summary | +| `/llms.md` | GET | LLM summary (Markdown content-type) | +| `/api/reference` | GET | Interactive OpenAPI documentation | + +### Meta Injection + +| Route | Method | Description | +|-------|--------|-------------| +| `/` | GET | Home page with feed injection + WebSite/Organization JSON-LD | +| `/post/:id` | GET | Post page with OG/Twitter/JSON-LD injection | +| `/user/:id/pages/:slug` | GET | Page with OG/Twitter meta injection | +| `/embed/:id` | GET | Embeddable post with initial state | +| `/embed/page/:id` | GET | Embeddable page with initial state | + +--- + +## Developer Experience + +Polymech isn't just SEO-friendly for end users โ€” it's built to be a joy for developers integrating with or extending the platform. + +> **Source:** Server entry point โ†’ [index.ts](../server/src/products/serving/index.ts) ยท [routes.ts](../server/src/products/serving/routes.ts) + +### OpenAPI 3.1 Specification โ€” `/doc` + +The entire API is described by a machine-readable OpenAPI 3.1 spec served at `/doc`. Every route โ€” from feed endpoints to image uploads to page CRUD โ€” is fully typed with Zod schemas that auto-generate the spec. No hand-written YAML, no drift between code and docs. + +``` +GET /doc โ†’ OpenAPI 3.1 JSON spec +``` + +This spec can be imported directly into Postman, Insomnia, or any OpenAPI-compatible tool for instant client generation. + +### Swagger UI โ€” `/ui` + +Classic Swagger UI is available at `/ui` for developers who prefer the traditional interactive API explorer. It connects to the same live OpenAPI spec: + +- Try-it-out for every endpoint +- Request/response schema visualization +- Bearer token authentication built in +- Auto-generated curl commands + +### Scalar API Reference โ€” `/reference` & `/api/reference` + +[Scalar](https://scalar.com/) provides a modern, polished alternative to Swagger UI. Polymech serves it at both `/reference` and `/api/reference`: + +- **Beautiful, searchable interface** โ€” grouped by tag (Serving, Posts, Media, Storage, etc.) +- **Pre-authenticated** โ€” Bearer token auto-filled from `SCALAR_AUTH_TOKEN` env var +- **Live request testing** โ€” send requests directly from the browser with real responses +- **Code generation** โ€” copy-paste ready snippets in curl, JavaScript, Python, Go, and more +- **Dark mode** โ€” because of course + +### Modular Product Architecture + +The server is organized as a registry of **Products** โ€” self-contained modules that each own their routes, handlers, workers, and lifecycle: + +| Product | Description | +|---------|-------------| +| **Serving** | Content delivery, SEO, feeds, exports, meta injection | +| **Images** | Upload, optimization, proxy, responsive variant generation | +| **Videos** | Upload, transcoding (HLS), thumbnail extraction | +| **Email** | Page-to-email rendering, SMTP delivery, template management | +| **Storage** | Virtual file system with ACL, mounts, and glob queries | +| **OpenAI** | AI chat, image generation, markdown tools | +| **Analytics** | Request tracking, geo-lookup, real-time streaming | +| **Ecommerce** | Cart, checkout, payment integration | + +Each product registers its own OpenAPI routes via `app.openapi(route, handler)`, so the spec always reflects exactly what's deployed. Adding a new product automatically exposes it in Swagger, Scalar, and `/doc`. + +### Zod-Powered Schema Validation + +All request and response schemas are defined with [Zod](https://zod.dev/) using `@hono/zod-openapi`. This gives you: + +- **Runtime validation** โ€” invalid requests are rejected with structured error messages before hitting business logic +- **Type safety** โ€” TypeScript types are inferred from schemas, zero manual type definitions +- **Auto-docs** โ€” Zod schemas feed directly into the OpenAPI spec with examples and descriptions +- **Composability** โ€” shared schemas (e.g., pagination, media items) are reused across products + +### Background Job Queue (PgBoss) + +Long-running tasks (video transcoding, email sending, cache warming) are managed through [PgBoss](https://github.com/timgit/pg-boss), a PostgreSQL-backed job queue: + +- Jobs are submittable via API: `POST /api/boss/job` +- Job status is queryable: `GET /api/boss/job/:id` +- Jobs can be cancelled, resumed, completed, or failed via dedicated endpoints +- Workers auto-register on startup and process jobs in the background + +### Real-Time Log Streaming + +System logs and analytics are streamable in real-time via SSE (Server-Sent Events): + +``` +GET /api/logs/system/stream โ†’ Live system logs +GET /api/analytics/stream โ†’ Live request analytics +``` + +This makes debugging in staging or production trivial โ€” just open the stream in a browser tab or curl. + +### WebSocket Support + +When `ENABLE_WEBSOCKETS=true`, the server initializes a WebSocket manager for real-time features like live feed updates and collaborative editing notifications. + +### Security & Middleware Stack + +The server applies a layered middleware stack to all routes: โ†’ see [security.md](./security.md) + +> **Source:** [auth.ts](../server/src/middleware/auth.ts) ยท [analytics.ts](../server/src/middleware/analytics.ts) ยท [rateLimiter.ts](../server/src/middleware/rateLimiter.ts) ยท [blocklist.ts](../server/src/middleware/blocklist.ts) + +| Layer | Description | +|-------|-------------| +| **CORS** | Fully permissive for API consumption from any origin | +| **Analytics** | Request tracking with IP resolution and geo-lookup | +| **Auth** | Optional JWT-based authentication via `Authorization: Bearer` header | +| **Admin** | Role-based access control for admin-only endpoints | +| **Compression** | Brotli/gzip compression on all responses | +| **Secure Headers** | CSP, X-Frame-Options (permissive for embeds), CORP disabled for cross-origin media | +| **Rate Limiting** | Configurable per-route rate limiting (disabled by default) | + +--- + +## Client-Side SEO & Performance + +The React SPA contributes to SEO through smart hydration, code splitting, and i18n support. + +> **Source:** [App.tsx](../src/App.tsx) ยท [i18n.tsx](../src/i18n.tsx) ยท [formatDetection.ts](../src/utils/formatDetection.ts) + +### HelmetProvider โ€” Dynamic `` Management + +The app is wrapped in `react-helmet-async`'s ``, enabling any component to dynamically inject ``, `<meta>`, and `<link>` tags into the document head. This complements the server-side meta injection โ€” the server provides OG/Twitter tags for crawlers, while Helmet handles client-side navigation. + +### Route-Based Code Splitting + +25+ routes use `React.lazy()` for on-demand loading, keeping the initial bundle small for faster First Contentful Paint: + +- **Eagerly loaded** (in initial bundle): `Index`, `Auth`, `Profile`, `UserProfile`, `TagPage`, `SearchResults` โ€” the high-traffic, SEO-critical pages +- **Lazy loaded**: `Post`, `UserPage`, `Wizard`, `AdminPage`, all playground routes, `FileBrowser`, `Tetris`, ecommerce routes + +This split ensures that unauthenticated, view-only visitors (including crawlers) get the fastest possible load time. + +### Initial State Hydration + +The client reads `window.__INITIAL_STATE__` injected by the server (see [Server-Side Rendering](#server-side-rendering--initial-state-injection)) to avoid waterfall API calls on first load. This covers: + +- `feed` โ€” Home page feed data +- `siteHomePage` โ€” Home page CMS content +- `profile` โ€” User profile on `/user/:id` pages + +### Client-Side i18n โ€” Language Detection & `<T>` Component + +> **Source:** [i18n.tsx](../src/i18n.tsx) ยท JSON translations in [src/i18n/*.json](../src/i18n/) + +The `<T>` component wraps translatable strings and resolves them against per-language JSON dictionaries. Language is determined via a cascading priority chain: + +1. **URL parameter** (`?lang=de`) โ€” highest priority, enables shareable translated links +2. **Cookie** (`lang=de`) โ€” persists across navigation, set when URL param is used +3. **Browser language** (`navigator.languages`) โ€” automatic fallback + +**13 supported languages:** English, Franรงais, Kiswahili, Deutsch, Espaรฑol, Nederlands, ๆ—ฅๆœฌ่ชž, ํ•œ๊ตญ์–ด, Portuguรชs, ะ ัƒััะบะธะน, Tรผrkรงe, ไธญๆ–‡ + +Translation dictionaries are loaded eagerly via Vite's `import.meta.glob` for instant availability. Missing keys auto-collect into localStorage for dictionary building (`downloadTranslations()` exports them as JSON). + +### Format Detection + +On app boot, `initFormatDetection()` probes browser support for modern image formats (AVIF, WebP). This informs the responsive image system which `<source>` elements to include in `<picture>` tags, ensuring optimal Core Web Vitals scores. + +--- + +## Summary + +Polymech treats SEO as a core platform feature, not an afterthought. Every content entity is automatically: + +- **Discoverable** โ€” via sitemap, RSS, merchant feed, and LLM endpoints +- **Previewable** โ€” with Open Graph, Twitter Cards, and JSON-LD for rich social sharing +- **Exportable** โ€” in 6+ formats (XHTML, HTML, PDF, Markdown, JSON, Email) +- **Translatable** โ€” with widget-level i18n that flows through all export formats +- **Optimized** โ€” with responsive images, lazy loading, LCP prioritization, and edge caching +- **Programmable** โ€” with a full OpenAPI spec and interactive documentation + +All of this works out of the box. No configuration needed. + +--- + +## TODO โ€” Pending Improvements + +### Critical + +- [x] **Canonical URLs** โ€” Add `<link rel="canonical">` to all XHTML/HTML exports and SPA pages to prevent duplicate content penalties across `.xhtml`, `.html`, and SPA routes +- [ ] **robots.txt** โ€” Serve a dynamic `robots.txt` at the root with sitemap references and crawl-delay directives. Currently missing entirely +- [x] **Hreflang tags** โ€” Add `<link rel="alternate" hreflang="...">` tags to multi-language pages so search engines serve the correct language variant per region +- [x] **Meta description per page** โ€” Pages and posts currently inherit a generic description. Wire the post `description` / page `meta.description` field into the `<meta name="description">` tag + +### High Priority + +- [x] **Structured data expansion** โ€” Add `BreadcrumbList` schema for page navigation paths and `WebSite` schema with `SearchAction` for sitelinks search box +- [-] **Sitemap pagination** โ€” Current sitemap is a single XML file. For large catalogs (1000+ products), split into sitemap index + per-entity sitemaps (`sitemap-posts.xml`, `sitemap-pages.xml`, `sitemap-products.xml`) +- [x] **Last-modified headers** โ€” Set `Last-Modified` and `ETag` on all content routes (posts, pages, feeds) to support conditional requests and improve crawler efficiency +- [ ] **Dynamic OG images** โ€” Auto-generate Open Graph images for pages/posts that don't have a cover image, using title + brand overlay +- [x] **JSON-LD for products** โ€” Add `Product` schema with `offers`, `aggregateRating`, and `brand` to product pages for rich shopping results + +### Medium Priority + +- [-] **AMP pages** โ€” Generate AMP-compliant HTML exports for posts to enable AMP carousel in Google mobile search +- [ ] **RSS per-user feeds** โ€” Currently only a global `/feed.xml`. Add per-user feeds at `/user/:id/feed.xml` so individual creators can be subscribed to +- [ ] **Merchant feed i18n** โ€” Product feed currently exports in the default language. Generate per-locale feeds (`/products-de.xml`, `/products-fr.xml`) using the i18n translation system +- [ ] **Preconnect / DNS-prefetch hints** โ€” Add `<link rel="preconnect">` for known external domains (CDN, image proxy, analytics) in the SPA shell +- [ ] **llms.txt expansion** โ€” Current `llms.txt` covers posts. Extend to include pages, products, and user profiles for broader AI agent discovery โ†’ [content.ts](../server/src/products/serving/content.ts) +- [ ] **WebSub / PubSubHubbub** โ€” Add `<link rel="hub">` to RSS feeds and implement WebSub pings on content publish for real-time feed reader updates + +### Low Priority / Nice-to-Have + +- [ ] **Core Web Vitals monitoring** โ€” Integrate CrUX API or web-vitals library to track LCP, FID, CLS and surface in analytics dashboard +- [ ] **Schema.org FAQ / HowTo** โ€” Auto-detect FAQ-style and tutorial page content and inject corresponding structured data +- [ ] **Twitter Cards validation** โ€” Add `twitter:site` and `twitter:creator` meta tags from user profiles for proper attribution +- [ ] **Video schema** โ€” Add `VideoObject` JSON-LD for posts containing video media items +- [ ] **IndexNow** โ€” Implement IndexNow API pings to Bing/Yandex on content publish for near-instant indexing + +--- + +### AEO โ€” Answer Engine Optimization + +Optimize content to be **cited as direct answers** by AI answer engines (Google AI Overviews, Bing Copilot, Perplexity, ChatGPT). + +- [ ] **Answer-first content blocks** โ€” In XHTML/HTML exports, structure pages with concise 40-60 word answer summaries at the top of each section, before the detailed explanation. AI engines pull individual passages โ€” clarity wins +- [ ] **FAQPage schema injection** โ€” Auto-detect Q&A patterns in page widgets (heading + paragraph pairs) and inject `FAQPage` JSON-LD. This is the #1 schema type cited by answer engines +- [ ] **QAPage schema for posts** โ€” When a post title is phrased as a question, wrap the body in `QAPage` structured data with `acceptedAnswer` +- [ ] **Text fragment identifiers** โ€” Add `#:~:text=` fragment links in sitemaps and llms.txt to guide AI engines to the most relevant passage in long-form pages +- [ ] **Featured snippet optimization** โ€” Ensure XHTML exports use `<table>`, `<ol>`, and `<dl>` for comparison content, definitions, and step-by-step guides โ€” these are the formats Google AI Overview pulls from +- [ ] **Concise `<meta name="description">` per section** โ€” For long pages with multiple sections, consider generating per-section meta descriptions via anchor-targeted structured data + +### GEO โ€” Generative Engine Optimization + +Optimize content to be **referenced and summarized** by generative AI systems (ChatGPT, Gemini, Claude, Perplexity). + +- [ ] **Entity authority via JSON-LD** โ€” Add `Organization`, `Person`, and `WebSite` schema with consistent `@id` URIs across all pages. AI models use entity graphs to determine source authority +- [ ] **E-E-A-T signals** โ€” Inject `author` schema with credentials, link to author profile pages, and add `datePublished` / `dateModified` to all content. Generative engines weight experience and freshness +- [ ] **Comparison and "X vs Y" pages** โ€” Create comparison page templates that AI systems frequently pull from when users ask evaluative questions +- [ ] **Fact-dense content markers** โ€” Add `ClaimReview` or `Dataset` schema where applicable. AI models prioritize statistically-backed and verifiable claims +- [ ] **Citation-optimized exports** โ€” In Markdown and JSON exports, include `source_url`, `author`, `published_date`, and `license` fields so AI systems can properly attribute when citing +- [ ] **AI Share of Voice tracking** โ€” Track brand mentions across ChatGPT, Perplexity, and Google AI Overviews to measure GEO effectiveness. Consider building an internal monitoring endpoint or integrating third-party tools + +### AI Crawler Management + +Control and optimize how AI training bots and inference crawlers interact with the platform. + +- [ ] **Dynamic `robots.txt` with AI directives** โ€” Serve a `robots.txt` that explicitly manages AI crawlers: allow `GPTBot`, `ClaudeBot`, `PerplexityBot` on content routes, but disallow on admin/API routes. Consider `Google-Extended` for training opt-in/out +- [ ] **`llms.txt` v2** โ€” Expand current `llms.txt` beyond posts to include: pages with summaries, product catalog overview, author profiles, and a structured capability description. Follow the emerging llms.txt spec with Markdown formatting +- [ ] **`llms-full.txt`** โ€” Generate a comprehensive full-content version at `/llms-full.txt` with all page content flattened into Markdown for deep AI ingestion +- [ ] **AI crawler rate limiting** โ€” Apply custom rate limits for known AI user agents (`GPTBot`, `ClaudeBot`, `CCBot`, `PerplexityBot`) to prevent content scraping from overloading the server while still allowing indexing +- [ ] **AI access analytics** โ€” Track and surface AI bot traffic separately in the analytics dashboard: which bots, how often, which routes, and bandwidth consumed. Use the existing user-agent parsing in [analytics.ts](../server/src/middleware/analytics.ts) +- [ ] **Structured content API for AI** โ€” Create a dedicated `/api/content` endpoint that returns semantically structured content (title, sections, facts, entities) optimized for LLM consumption, distinct from the user-facing API +- [ ] **IETF AI Preferences compliance** โ€” Monitor the IETF "AI Preferences Working Group" (launched 2025) for the standardized machine-readable AI access rules spec. Implement when finalized โ€” will likely supersede or extend `robots.txt` for AI + +### AI-Native Content Formats + +- [ ] **Markdown-first content pipeline** โ€” Ensure all page widgets can export clean, semantic Markdown. This is the preferred format for LLM ingestion and is used by `llms.txt`, `llms-full.txt`, and AI-friendly feeds +- [ ] **Structured knowledge base export** โ€” Generate a `/knowledge.json` endpoint that exports the entire content catalog as a structured knowledge graph (entities, relationships, facts) for RAG pipelines and enterprise AI integrations +- [ ] **MCP (Model Context Protocol) server** โ€” Expose platform content as an MCP resource so AI assistants (Claude, Cursor, etc.) can directly query posts, pages, and products as context โ€” leveraging the existing REST API as the backend +- [ ] **AI-friendly RSS** โ€” Extend RSS feed items with full content (not just excerpts), structured metadata, and `<media:content>` tags so AI feed consumers get complete context without needing to crawl diff --git a/packages/ui/docs/storage.md b/packages/ui/docs/storage.md new file mode 100644 index 00000000..ee9bd3de --- /dev/null +++ b/packages/ui/docs/storage.md @@ -0,0 +1,117 @@ +# Virtual File System (VFS) & Access Control List (ACL) + +The Polymech Storage module implements a robust Virtual File System (VFS) with an integrated Access Control List (ACL) engine. This system presents a unified file interface across different mount points, providing advanced features like bind mounting, strict path sanitization, streaming, compression, and granular permission enforcement. + +## 1. VFS Mounts + +Mounts define the physical or virtual storage locations exposed by the VFS API. + +### Configuration (`config/vfs.json`) +Mount configs list the available storage volumes. It can be overridden per environment (e.g. `vfs-production.json`) or via the `VFS_CONFIG` environment variable. + +```json +[ + { + "name": "root", + "type": "local", + "path": "./data/root" + }, + { + "name": "assets", + "type": "local", + "path": "./data/assets" + } +] +``` + +### Special Mount Types +* **Named Mounts**: Declared directly in configured JSON files. (e.g. `root`, `assets`). +* **Home Mount (`home`)**: A virtual mount that maps to `{root_mount_path}/{user_uuid}`. This provides each user with an isolated personal storage area. The directory automatically creates itself on first access. +* **User Mount (`user:{uuid}`)**: Administrative or cross-user syntax to directly refer to a specific user's home folder. +* **UUID Mount (`{uuid}`)**: Also acts as a direct alias to a specific user's home folder, often used for sharing files anonymously or cross-user. + +## 2. Bind Mounts + +Bind mounts map a path from one mount into a different mount. This allows data to appear in multiple locations without duplicating files, akin to Linux `mount --bind`. + +### Configuration (`config/vfs-binds.json`) +Can also be overridden by environment (e.g. `vfs-binds-production.json`). + +```json +[ + { + "id": "home-assets", + "source": { "mount": "assets" }, + "target": { "mount": "home", "path": "assets" }, + "acl": "source" + } +] +``` +* **`acl: "source"`**: Permissions are evaluated based on the source mount's ACL contexts. +* **`acl: "inherit"`**: Permissions evaluate against the target mount's context (e.g., if bound to a user's home, the user owns it). + +## 3. Access Control Lists (ACL) + +The VFS features a granular, path-based ACL system. Every physical mount root or user's home directory maintains a `vfs-settings.json` file defining ownership and permissions. + +### Settings Configuration (`vfs-settings.json`) +If a `vfs-settings.json` doesn't exist, it is auto-generated with the default owner when first accessed. + +```json +{ + "owner": "3bb4cfbf-318b-44d3-a9d3-35680e738421", + "groups": [], + "acl": [ + { + "path": "/", + "permissions": ["read", "list"], + "userId": "anonymous" + }, + { + "path": "/shared", + "permissions": ["read", "list", "write", "mkdir", "delete", "rename", "copy"], + "userId": "authenticated" + } + ] +} +``` + +### Roles and Identifiers +* **Owner (`owner` field)**: The system owner or user UUID. Has implicit full control. System mounts use a synthetic UUID (`00000000-0000-0000-0000-000000000000`). +* **`userId` values in ACL**: + * `anonymous`: Any unauthenticated public requests. + * `authenticated`: Any user with a valid Supabase token. + * `{uuid}`: A specific Supabase user ID. +* **Permissions Options**: `read`, `list`, `write`, `mkdir`, `delete`, `rename`, `copy`. + +### API ACL Management +Users or Admins can manipulate ACL records on their owned mounts via explicit routes: +* `POST /api/vfs/acl/grant/{mount}` +* `POST /api/vfs/acl/revoke/{mount}` + +## 4. API Endpoints and Interactions + +All VFS endpoints follow a `/{action}/{mount}/{subpath}` format. + +* **List / Glob Search**: `/api/vfs/ls/{mount}/*` + * Query params: `glob=*` (pattern matching), `includeSize=true` (recursively calculates folder sizing with 5-day caching). + * Automatically applies `.gitignore` filtering when listing physical local mounts via `isIgnored()`. +* **Read / Serve**: + * `/api/vfs/read/{mount}/*`: Returns raw text. + * `/api/vfs/get/{mount}/*`: Serves files with correct MIME types and supports HTTP **Range requests** (`206 Partial Content`), vital for streaming large media components (video/audio). +* **Write / Upload**: + * `/api/vfs/write/{mount}/*`: Direct HTTP PUT text replacement. + * `/api/vfs/upload/{mount}/*`: Standard `multipart/form-data` uploads. Piped directly to disk recursively to mitigate loading entirely in memory for large files. +* **ZIP Compress**: `/api/vfs/compress/{mount}/*` + * Dynamically streams a ZIP of the targeted folder structure. Rate-limited to 3 times per minute. Refuses anonymous usage implicitly. +* **Deep Search Engine**: `/api/vfs/search/{mount}/*` + * Implements deep recursive search. + * Can force-synchronize to Supabase full-text search systems via Admin hooks (`/api/vfs/admin/index`). + +## 5. Security & Path Traversal Protection + +The primary VFS router enforces extreme security to prevent Directory Traversal or Payload injection. + +* **Bans System**: + * Detecting null-byte injection (`%00`), URL-encoded bypasses (`..=%5c`), double-encoded bypasses (`%252e`), or trailing malicious slashes will instantly enforce an IP Auto-Ban logging routine resulting in a `403 Fuck You` text response unless requested explicitly from localhost testing vectors. +* **Path Sanitization**: `sanitizeWritePath()` forcefully filters malformed characters (accidental square brackets, extra dots, invalid symbols) for created directories or files. diff --git a/packages/ui/docs/support-chat.md b/packages/ui/docs/support-chat.md new file mode 100644 index 00000000..e583492c --- /dev/null +++ b/packages/ui/docs/support-chat.md @@ -0,0 +1,150 @@ +# Support Chat + +Embeddable AI support chat built on `ChatPanel` with a fixed `support` preset (no header, no sidebar). System prompts are assembled from [`defaults.ts`](../src/modules/ai/defaults.ts), injecting the user's UI language and named context blocks automatically. + +--- + +## Route + +`/support-chat` โ€” public, no auth required. Registered in `App.tsx` via the `SupportChat` page. + +--- + +## Architecture + +``` +SupportChat (page/embed) + โ””โ”€โ”€ ChatPanel preset="support" + โ”œโ”€โ”€ useChatEngine('support') โ† isolated localStorage namespace + โ”œโ”€โ”€ buildSupportPrompt(context) โ† from defaults.ts + โ”‚ โ”œโ”€โ”€ BASE_SUPPORT_PROMPT + โ”‚ โ”œโ”€โ”€ getCurrentLang() โ† from i18n.tsx + โ”‚ โ””โ”€โ”€ SUPPORT_CONTEXTS[key...] โ† named blocks + โ””โ”€โ”€ seed useEffect (once on mount) + sets provider / model / tools / systemPrompt +``` + +### localStorage isolation + +Each `ChatPanel` preset gets its own storage namespace via `useChatEngine(preset)`: + +| Preset | Key prefix | +|---|---| +| `developer` / `standard` / `simple` | `developer-settings-*` etc. | +| `support` | `support-settings-*` | + +This prevents PlaygroundChat from polluting SupportChat settings (and vice versa). + +--- + +## System Prompt Assembly + +**File:** `src/modules/ai/defaults.ts` + +```ts +buildSupportPrompt(contexts?) +// โ†’ BASE_SUPPORT_PROMPT +// + "Always reply in Franรงais (language code: fr)." โ† auto from getCurrentLang() +// + SUPPORT_CONTEXTS['shipping-rates'] โ† if in contexts +``` + +### Default contexts + +```ts +export const DEFAULT_CONTEXTS: SupportContextKey[] = ['shipping-rates']; +``` + +Applied when no `context` prop is passed. + +### Adding a new context block + +```ts +// src/modules/ai/defaults.ts +export const SUPPORT_CONTEXTS: Record<string, string> = { + 'shipping-rates': `...`, + 'returns': `## Returns Policy\n...`, // โ† add here +}; +``` + +The key becomes the `SupportContextKey` union type automatically. + +--- + +## Embedding + +### Standalone page (default) + +```tsx +<SupportChat /> +// Uses: openrouter / gpt-4o, search tools ON, DEFAULT_CONTEXTS +``` + +### With custom contexts + +```tsx +<SupportChat context={['shipping-rates', 'returns']} /> +``` + +### With different model / provider + +```tsx +<SupportChat + provider="openai" + model="gpt-4o-mini" + context={['shipping-rates']} +/> +``` + +### With live runtime context (e.g. current order) + +Use `ChatPanel` directly with `getContext` โ€” called before every send: + +```tsx +<ChatPanel + preset="support" + context={['shipping-rates']} + getContext={() => `User is viewing order #${orderId}`} +/> +``` + +### All props + +#### `SupportChat` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `context` | `SupportContextKey[]` | `DEFAULT_CONTEXTS` | Context blocks appended to base prompt | +| `provider` | `string` | `'openrouter'` | AI provider | +| `model` | `string` | `'openai/gpt-4o'` | AI model | +| `searchTools` | `boolean` | `true` | Site search tools | +| `pageTools` | `boolean` | `false` | Page/content tools | +| `imageTools` | `boolean` | `false` | Image generation tool | +| `vfsTools` | `boolean` | `false` | VFS file tools | + +#### `ChatPanel` (additional props relevant to support embeds) + +| Prop | Type | Description | +|---|---|---| +| `context` | `SupportContextKey[]` | Same as above, passed through | +| `initialSystemPrompt` | `string` | Overrides `buildSupportPrompt` entirely | +| `getContext` | `() => string \| null` | Dynamic context injected before each send | +| `extraTools` | `() => any[]` | Additional tool definitions | + +--- + +## Navigation + +A `MessageSquare` icon button is always visible in `TopNavigation` (public, no auth guard), highlighting when the route is active. + +--- + +## Files + +| File | Role | +|---|---| +| `src/pages/SupportChat.tsx` | Page component + embed wrapper | +| `src/modules/ai/defaults.ts` | Prompt registry (`BASE_SUPPORT_PROMPT`, `SUPPORT_CONTEXTS`, `buildSupportPrompt`) | +| `src/modules/ai/ChatPanel.tsx` | Panel with `support` preset + `initial*` seed props + `context` prop | +| `src/modules/ai/useChatEngine.ts` | Engine hook, namespaced via `namespace` param | +| `src/App.tsx` | `/support-chat` route | +| `src/components/TopNavigation.tsx` | Nav icon button | diff --git a/packages/ui/docs/tetris-cloud-architecture.md b/packages/ui/docs/tetris/tetris-cloud-architecture.md similarity index 100% rename from packages/ui/docs/tetris-cloud-architecture.md rename to packages/ui/docs/tetris/tetris-cloud-architecture.md diff --git a/packages/ui/docs/tetris-llm.md b/packages/ui/docs/tetris/tetris-llm.md similarity index 100% rename from packages/ui/docs/tetris-llm.md rename to packages/ui/docs/tetris/tetris-llm.md diff --git a/packages/ui/docs/tetris-neural-ai.md b/packages/ui/docs/tetris/tetris-neural-ai.md similarity index 100% rename from packages/ui/docs/tetris-neural-ai.md rename to packages/ui/docs/tetris/tetris-neural-ai.md diff --git a/packages/ui/docs/tetris-neural.md b/packages/ui/docs/tetris/tetris-neural.md similarity index 100% rename from packages/ui/docs/tetris-neural.md rename to packages/ui/docs/tetris/tetris-neural.md diff --git a/packages/ui/docs/tetris-strategies.md b/packages/ui/docs/tetris/tetris-strategies.md similarity index 100% rename from packages/ui/docs/tetris-strategies.md rename to packages/ui/docs/tetris/tetris-strategies.md diff --git a/packages/ui/docs/tiles.md b/packages/ui/docs/tiles.md new file mode 100644 index 00000000..ea8f0a90 --- /dev/null +++ b/packages/ui/docs/tiles.md @@ -0,0 +1,35 @@ +# GridSearch Polygon Rendering & Server Freeze Mitigation + +## The Problem +When dealing with GridSearches containing over 5,000+ polygons (e.g., all towns in Spain), querying `better-sqlite3`, parsing GeoPackage WKB into JS objects, putting them into an array, and then running `JSON.stringify()` on a 100MB+ object tree freezes the V8 JavaScript engine. The garbage collector natively blocks the main event loop while traversing this massive JS object. + +Even with asynchronous yields (e.g., `setTimeout(resolve, 0)`), constructing massive JavaScript arrays of multi-polygons will lock up the Node.js thread and cause other API requests to timeout. + +## Architectural Options + +### 1. Raw String Streaming (Lowest Effort, High Impact) +Skip building the 100MB+ V8 object tree entirely. +- **How:** Query SQLite geometries and stream the raw JSON strings directly into the HTTP response (e.g. `c.streamText()`). +- **Pros:** Peak memory drops from 500MB+ to ~1MB. The V8 engine never builds the massive object tree, preventing the GC freeze. +- **Cons:** The browser still has to download and parse a massive JSON file at once, which may freeze the frontend map rendering momentarily. + +### 2. Pre-generate Static `.geojson` Files (Best Performance) +Instead of asking the database for polygons *every time* a map requests them, generate the full polygons file once. +- **How:** When `gs.enumerate()` creates a search, it also writes a `{basename}-polygons.json` file to the `searches/` directory. The UI fetches this static file directly. +- **Pros:** Perfectly zero-cost for the Node backend at request-time. NGINX or Hono streams the file instantly without touching the heavy event loop. +- **Cons:** Increases disk usage. The initial file generation still freezes the server briefly (unless offloaded to a background task like PgBoss). + +### 3. Native Worker Threads (`worker_threads`) +Offload the synchronous SQLite querying to a separate Node.js thread. +- **How:** Spin up a `piscina` worker pool. The worker thread opens a separate `better-sqlite3` connection, does the parsing, stringifies it, and passes the buffer back. +- **Pros:** Main event loop remains 100% responsive. +- **Cons:** Significant architectural overhead. Transferring 100MB strings via `postMessage` still incurs a minor memory/serialization hit. + +### 4. Vector Tiles / .mvt & Lazy Loading (The "Proper" GIS Way) +Maplibre GL natively supports loading data in Vector Tiles (zoom + X + Y bounding boxes) rather than pulling all 5,000 geometries at once. +- **How:** The UI requests data via `/api/locations/gridsearch/tiles/{z}/{x}/{y}`. The backend dynamically queries `better-sqlite3` strictly for polygons intersecting that tile envelope. +- **Pros:** Infinitely scalable. 5 million polygons won't freeze the server or the browser. +- **Cons:** Highest effort. Requires implementing an MVT generation proxy (using `geojson-vt` or PostGIS equivalents) and pagination in the client. + +## Current Mitigation +Currently, a temporary fix uses `await new Promise(r => setTimeout(r, 0))` in the `/api/locations/gridsearch/polygons` endpoint every 50 iterations to yield to the event loop. However, moving towards **Option 2** (Static generation) or **Option 4** (Vector tiles) is strongly recommended for production stability. diff --git a/packages/ui/docs/video-bg.html b/packages/ui/docs/video-bg.html new file mode 100644 index 00000000..534781ae --- /dev/null +++ b/packages/ui/docs/video-bg.html @@ -0,0 +1,45 @@ +<div class="relative flex flex-col justify-end overflow-hidden bg-gray-100 dark:bg-gray-900 bg-cover bg-center" + style="min-height: 500px; max-height: 80vh; background-image: url('https://service.polymech.info/api/images/render?url=https%3A%2F%2Fytoadlpbdguriiccjnip.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2Fpictures%2F3bb4cfbf-318b-44d3-a9d3-35680e738421%2F1772547102918-version.png&width=1280&format=avif');"> + <video class="absolute inset-0 w-full h-full object-cover" + src="http://localhost:3333/api/videos/jobs/1cb36a43-e781-40da-bee8-b0e82b156bf4/hls/playlist.m3u8" + poster="https://service.polymech.info/api/images/render?url=https%3A%2F%2Fytoadlpbdguriiccjnip.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2Fpictures%2F3bb4cfbf-318b-44d3-a9d3-35680e738421%2F1772547102918-version.png&width=1280&format=avif" + preload="metadata" autoplay muted loop playsinline aria-hidden="true"></video> + <div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent"></div> + + <!-- Pause/Play toggle (WCAG 2.2.2) --> + <button + onclick="var v=this.closest('div').querySelector('video'); if(v.paused){v.play();this.textContent='โธ'}else{v.pause();this.textContent='โ–ถ'}" + class="absolute top-3 right-3 z-20 bg-black/40 hover:bg-black/60 text-white rounded-full w-9 h-9 flex items-center justify-center text-sm transition-colors" + aria-label="Pause or play background video" title="Pause/Play video">โธ</button> + + <div class="relative z-10 w-full pt-16 sm:pt-20 pb-4 sm:pb-6"> + <div class="bg-black/10 dark:bg-black/60 px-5 sm:px-8 lg:px-10 py-6 sm:py-8"> + <h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-white drop-shadow-lg max-w-4xl"> + Polymer Processing Equipment & Engineering Solutions + </h1> + <p class="mt-2 sm:mt-3 text-sm sm:text-base lg:text-lg text-gray-200 drop-shadow max-w-3xl"> + Extrusion, compounding & recycling systems for education, labs, and small-scale production โ€” backed + by our knowledgebase, custom software, and manufacturing services. + </p> + <div class="mt-4 sm:mt-5 flex flex-col sm:flex-row sm:space-x-4 space-y-3 sm:space-y-0"> + <a href="/categories/products" + class="inline-block text-center bg-white/20 hover:bg-white/30 backdrop-blur-sm border border-white/30 text-white font-semibold py-2.5 sm:py-2 px-5 rounded-md transition-colors"> + Explore Products + </a> + <a href="/user/cgo/pages/about-contact" + class="inline-block text-center bg-white/15 hover:bg-white/25 backdrop-blur-sm border border-white/30 text-white font-semibold py-2.5 sm:py-2 px-5 rounded-md transition-colors"> + Contact Us + </a> + </div> + </div> + </div> +</div> + +<!-- Reduced-motion: pause video for users who prefer less motion (WCAG) --> +<style> + @media (prefers-reduced-motion: reduce) { + video { + display: none !important; + } + } +</style> \ No newline at end of file diff --git a/packages/ui/docs/widgets-api.md b/packages/ui/docs/widgets-api.md new file mode 100644 index 00000000..988265a4 --- /dev/null +++ b/packages/ui/docs/widgets-api.md @@ -0,0 +1,1883 @@ +# Widget & Container API โ€” Complete Interface Proposal + +> This document proposes a unified, type-safe API for the widget/container system. +> It consolidates current patterns from the codebase and introduces missing lifecycle hooks, +> nested layout management, and a proper context contract. + +--- + +## 1. Widget Definition (Registry) + +**Current**: [src/lib/widgetRegistry.ts](../src/lib/widgetRegistry.ts) ยท [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts) + +```ts +interface WidgetDefinition<P extends BaseWidgetProps = BaseWidgetProps> { + /** React component โ€” receives merged props + context */ + component: React.ComponentType<P>; + /** Edit-mode-only component (optional, lazy-loaded) */ + editComponent?: React.LazyExoticComponent<React.ComponentType<P>>; + /** Static metadata for palette, search, AI layout generation */ + metadata: WidgetMetadata<P>; + /** Preview thumbnail component for drag preview */ + previewComponent?: React.ComponentType<P>; + + // โ”€โ”€โ”€ Nested Layout Support โ”€โ”€โ”€ + /** Declare sub-layouts this widget manages (tabs, accordion panels, etc.) */ + getNestedLayouts?: (props: P) => NestedLayoutRef[]; + /** How nested layout data is embedded โ€” determines save strategy */ + nestedLayoutStrategy?: 'embedded' | 'linked'; + + // โ”€โ”€โ”€ Lifecycle Hooks โ”€โ”€โ”€ + /** Called once when widget is first added to a container */ + onInit?: (context: WidgetLifecycleContext) => Partial<P> | void; + /** Called before the parent layout saves โ€” return final props */ + onBeforeSave?: (props: P, context: WidgetLifecycleContext) => P; + /** Called when widget is removed from a container */ + onDestroy?: (props: P, context: WidgetLifecycleContext) => void; + /** Validate props โ€” return error messages or null */ + validate?: (props: P) => Record<string, string> | null; + + // โ”€โ”€โ”€ Translation โ”€โ”€โ”€ + /** List prop keys that contain translatable content */ + translatableProps?: (keyof P)[]; + /** Custom extraction for complex nested translatable content */ + extractTranslatableContent?: (props: P) => TranslatableEntry[]; + + // โ”€โ”€โ”€ Export โ”€โ”€โ”€ + /** Render to static HTML for email/PDF export */ + renderStatic?: (props: P, context: ExportContext) => string; +} +``` + +### Widget Metadata + +```ts +interface WidgetMetadata<P = Record<string, any>> { + id: string; // Unique registry key, e.g. 'tabs-widget' + name: string; // Display name (translatable) + category: WidgetCategory; // Palette grouping + description: string; // Tooltip/search description + icon?: React.ComponentType; // Lucide icon component + thumbnail?: string; // Image URL for visual palette + defaultProps?: Partial<P>; // Initial values on widget creation + configSchema?: ConfigSchema<P>; // Property editor definition + minSize?: { width: number; height: number }; + resizable?: boolean; + tags?: string[]; // Search/AI discovery tags + + // โ”€โ”€โ”€ New โ”€โ”€โ”€ + /** Widget capabilities for container-level feature gating */ + capabilities?: WidgetCapability[]; + /** Maximum instances per page (e.g. 1 for 'home' widget) */ + maxInstances?: number; + /** Restrict to specific container types */ + allowedContainers?: ContainerType[]; +} + +type WidgetCategory = 'control' | 'display' | 'layout' | 'chart' | 'system' | 'custom'; +type WidgetCapability = 'nested-layout' | 'translatable' | 'data-bound' | 'interactive' | 'exportable'; +``` + +--- + +## 2. Widget Props Contract + +**Current**: Props are spread as `{...(widget.props || {})}` in [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) (line 776) + +```ts +/** Every widget component receives these props โ€” non-negotiable */ +interface BaseWidgetProps { + // โ”€โ”€โ”€ Identity โ”€โ”€โ”€ + widgetInstanceId: string; // Unique instance ID within layout + widgetDefId: string; // Registry ID (e.g. 'tabs-widget') + + // โ”€โ”€โ”€ Mode โ”€โ”€โ”€ + isEditMode: boolean; // View vs Edit mode + enabled?: boolean; // Soft-disable (grayed out in edit, hidden in view) + + // โ”€โ”€โ”€ Mutations โ”€โ”€โ”€ + onPropsChange: (partial: Record<string, any>) => Promise<void>; + + // โ”€โ”€โ”€ Selection (edit mode) โ”€โ”€โ”€ + selectedWidgetId?: string | null; + onSelectWidget?: (widgetId: string, pageId?: string) => void; + editingWidgetId?: string | null; + onEditWidget?: (widgetId: string | null) => void; + selectedContainerId?: string | null; + onSelectContainer?: (containerId: string, pageId?: string) => void; + + // โ”€โ”€โ”€ Data โ”€โ”€โ”€ + contextVariables?: Record<string, any>; // Page-level template variables + pageContext?: Record<string, any>; // Page metadata (slug, locale, etc.) + + // โ”€โ”€โ”€ Styling โ”€โ”€โ”€ + customClassName?: string; // User-defined Tailwind classes +} +``` + +### Specialized Prop Interfaces (examples) + +```ts +interface TabsWidgetProps extends BaseWidgetProps { + tabs: TabDef[]; + activeTabId?: string; + orientation?: 'horizontal' | 'vertical'; + tabBarPosition?: 'top' | 'bottom' | 'left' | 'right'; + tabBarClassName?: string; + contentClassName?: string; +} + +interface TabDef { + id: string; + label: string; + icon?: string; + layoutId: string; + layoutData?: PageLayout; // Embedded layout (source of truth for save) +} +``` + +--- + +## 3. Config Schema (Property Editor) + +**Current**: `configSchema` in each widget registration in [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts) + +```ts +type ConfigSchema<P> = { + [K in keyof Partial<P>]: ConfigField; +} & { + /** Virtual fields that map to multiple props (e.g. mountAndPath โ†’ mount + path) */ + [virtualKey: string]: ConfigField; +}; + +interface ConfigField { + type: ConfigFieldType; + label: string; + description?: string; + default?: any; + + // โ”€โ”€โ”€ Conditional visibility โ”€โ”€โ”€ + /** Only show this field when condition is met */ + showWhen?: { field: string; equals: any } | ((props: any) => boolean); + /** Disable editing when condition is met */ + disableWhen?: { field: string; equals: any }; + + // โ”€โ”€โ”€ Type-specific options โ”€โ”€โ”€ + options?: { value: string; label: string }[]; // for 'select' + min?: number; max?: number; step?: number; // for 'number'/'range' + multiSelect?: boolean; // for 'imagePicker' + mountKey?: string; pathKey?: string; // for 'vfsPicker' + + // โ”€โ”€โ”€ Validation โ”€โ”€โ”€ + required?: boolean; + pattern?: RegExp; + customValidate?: (value: any, allProps: any) => string | null; + + // โ”€โ”€โ”€ Groups โ”€โ”€โ”€ + group?: string; // Visual grouping in property panel + order?: number; // Sort order within group +} + +type ConfigFieldType = + | 'text' | 'number' | 'boolean' | 'select' | 'selectWithText' + | 'color' | 'range' | 'classname' | 'markdown' + | 'imagePicker' | 'pagePicker' | 'vfsPicker' + | 'tabs-editor' | 'json' + | 'component'; // Custom React component as editor +``` + +--- + +## 4. Widget Instance (Stored in Layout) + +**Current**: [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) (line 5) + +```ts +interface WidgetInstance { + id: string; // Unique per layout + widgetId: string; // References WidgetDefinition.metadata.id + props?: Record<string, any>; // Serialized props (JSON-safe) + order?: number; // Sort order within container + + // โ”€โ”€โ”€ Flex Container placement โ”€โ”€โ”€ + rowId?: string; + column?: number; + + // โ”€โ”€โ”€ Provenance โ”€โ”€โ”€ + snippetId?: string; // Tracks which snippet spawned this widget + createdAt?: number; // For undo history / conflict resolution +} +``` + +--- + +## 5. Container Types + +**Current**: [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) (lines 17-61) + +```ts +type ContainerType = 'container' | 'flex-container'; + +/** Traditional grid container */ +interface LayoutContainer { + id: string; + type: 'container'; + columns: number; + gap: number; + widgets: WidgetInstance[]; + children: LayoutContainer[]; // Recursive nesting + order?: number; + settings?: ContainerSettings; +} + +/** Row-based flex container with adjustable columns */ +interface FlexibleContainer { + id: string; + type: 'flex-container'; + rows: RowDef[]; + widgets: WidgetInstance[]; // Widgets reference rowId + column + gap: number; + order?: number; + settings?: ContainerSettings; +} + +type AnyContainer = LayoutContainer | FlexibleContainer; + +interface ContainerSettings { + collapsible?: boolean; + collapsed?: boolean; + title?: string; + showTitle?: boolean; + customClassName?: string; + enabled?: boolean; + + // โ”€โ”€โ”€ New โ”€โ”€โ”€ + /** Background color/gradient */ + background?: string; + /** Padding override (Tailwind class) */ + padding?: string; + /** Max width constraint */ + maxWidth?: string; + /** Visibility conditions */ + visibleWhen?: VisibilityCondition; +} + +interface VisibilityCondition { + /** 'always' | 'authenticated' | 'role:admin' | custom expression */ + rule: string; + /** Invert the condition */ + negate?: boolean; +} +``` + +### Row Definition (FlexContainer) + +```ts +interface RowDef { + id: string; + columns: ColumnDef[]; + gap?: number; + sizing?: 'constrained' | 'unconstrained'; + cellAlignments?: ('stretch' | 'start' | 'center' | 'end')[]; + padding?: string; +} + +interface ColumnDef { + width: number; + unit: 'fr' | 'px' | 'rem' | '%'; + minWidth?: number; +} +``` + +--- + +## 6. Page Layout (Top-Level) + +**Current**: [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) (line 63) + +```ts +interface PageLayout { + id: string; + name: string; + containers: AnyContainer[]; + createdAt: number; + updatedAt: number; + loadedBundles?: string[]; + rootTemplate?: string; + + // โ”€โ”€โ”€ New โ”€โ”€โ”€ + /** Schema version for migration */ + version: string; + /** Layout-level metadata */ + meta?: { + description?: string; + thumbnail?: string; + /** Lock layout from editing */ + locked?: boolean; + }; +} + +interface RootLayoutData { + pages: Record<string, PageLayout>; + version: string; + lastUpdated: number; +} +``` + +--- + +## 7. Nested Layout Management + +**Current**: `getNestedLayouts` + SYNC-BACK pattern in [src/components/widgets/TabsWidget.tsx](../src/components/widgets/TabsWidget.tsx) + +### Problem Space + +Widgets like Tabs, Accordions, and LayoutContainerWidget manage **sub-layouts** โ€” independent `PageLayout` trees embedded as props. The current system uses a SYNC-BACK effect to bridge `loadedPages` (live editing state) back to widget props (persistence), which causes [race conditions](./nested-ex.md). + +### Proposed: NestedLayoutManager + +```ts +interface NestedLayoutRef { + id: string; // Semantic ID (e.g. 'tab-1') + label: string; // Display label + layoutId: string; // Key in loadedPages +} + +/** Provided by LayoutContext to widgets that declare nested layouts */ +interface NestedLayoutManager { + /** Register a sub-layout โ€” hydrates from embedded data, returns cleanup */ + register(layoutId: string, initialData?: PageLayout): () => void; + /** Check if a layout has been authoritatively hydrated */ + isHydrated(layoutId: string): boolean; + /** Get current layout data for serialization (save path) */ + getLayoutData(layoutId: string): PageLayout | undefined; + /** Subscribe to layout changes (replaces SYNC-BACK polling) */ + onLayoutChange(layoutId: string, callback: (layout: PageLayout) => void): () => void; +} +``` + +### Usage in TabsWidget (proposed) + +```tsx +const TabsWidget: React.FC<TabsWidgetProps> = ({ tabs, isEditMode, onPropsChange }) => { + const { nestedLayouts } = useLayout(); + + // Register all tab layouts on mount + useEffect(() => { + if (!isEditMode) return; + const cleanups = tabs.map(t => + nestedLayouts.register(t.layoutId, t.layoutData) + ); + return () => cleanups.forEach(fn => fn()); + }, [tabs.length]); // Only re-register when tabs are added/removed + + // Subscribe to changes โ€” replaces SYNC-BACK + useEffect(() => { + if (!isEditMode) return; + const unsubs = tabs.map(t => + nestedLayouts.onLayoutChange(t.layoutId, (layout) => { + onPropsChange({ + tabs: tabs.map(tab => + tab.layoutId === t.layoutId ? { ...tab, layoutData: layout } : tab + ) + }); + }) + ); + return () => unsubs.forEach(fn => fn()); + }, [tabs]); + + return ( + <GenericCanvas + pageId={currentTab.layoutId} + initialLayout={currentTab.layoutData} + isEditMode={isEditMode} + /> + ); +}; +``` + +**Key difference**: No polling. No timestamp comparison. The `onLayoutChange` callback fires only on **real mutations** (widget add/remove/move/prop-change), not on hydration. + +--- + +## 8. Widget Lifecycle Context + +```ts +interface WidgetLifecycleContext { + /** Current page ID */ + pageId: string; + /** Current locale */ + locale: string; + /** Access to the layout manager */ + layout: { + addWidget: (containerId: string, widgetId: string, props?: any) => Promise<void>; + removeWidget: (containerId: string, widgetInstanceId: string) => Promise<void>; + getContainer: (containerId: string) => AnyContainer | undefined; + }; + /** Access to nested layout manager (if widget has nested layouts) */ + nestedLayouts?: NestedLayoutManager; + /** User info */ + user?: { id: string; role: string }; +} +``` + +--- + +## 9. Container Rendering Contract + +**Current**: [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) ยท [src/modules/layout/LayoutContainerView.tsx](../src/modules/layout/LayoutContainerView.tsx) + +```ts +/** Props passed to container renderers */ +interface ContainerRendererProps { + container: AnyContainer; + pageId: string; + isEditMode: boolean; + depth: number; // Nesting depth (for styling/limits) + + // โ”€โ”€โ”€ Edit mode only โ”€โ”€โ”€ + onAddWidget?: (widgetId: string, snippetId?: string) => void; + onRemoveWidget?: (widgetInstanceId: string) => void; + onMoveWidget?: (widgetInstanceId: string, direction: Direction) => void; + onReorderWidget?: (widgetInstanceId: string, newIndex: number) => void; + + // โ”€โ”€โ”€ Selection โ”€โ”€โ”€ + selectedWidgetId?: string | null; + onSelectWidget?: (widgetId: string) => void; + editingWidgetId?: string | null; + onEditWidget?: (widgetId: string | null) => void; + selectedContainerId?: string | null; + onSelectContainer?: (containerId: string) => void; + + // โ”€โ”€โ”€ Context โ”€โ”€โ”€ + contextVariables?: Record<string, any>; + pageContext?: Record<string, any>; +} + +type Direction = 'up' | 'down' | 'left' | 'right'; +``` + +--- + +## 10. Layout Context API + +**Current**: [src/modules/layout/LayoutContext.tsx](../src/modules/layout/LayoutContext.tsx) + +```ts +interface LayoutContextValue { + // โ”€โ”€โ”€ State โ”€โ”€โ”€ + loadedPages: Map<string, PageLayout>; + isLoading: boolean; + /** Tracks which layouts have been authoritatively hydrated */ + hydratedIds: ReadonlySet<string>; + + // โ”€โ”€โ”€ Page Operations โ”€โ”€โ”€ + loadPageLayout: (pageId: string, pageName?: string) => Promise<void>; + hydratePageLayout: (pageId: string, layout: PageLayout) => void; + savePageLayout: (pageId: string) => Promise<void>; + + // โ”€โ”€โ”€ Container Operations โ”€โ”€โ”€ + addPageContainer: (pageId: string, type?: ContainerType) => Promise<void>; + removePageContainer: (pageId: string, containerId: string) => Promise<void>; + updateContainerSettings: (pageId: string, containerId: string, settings: Partial<ContainerSettings>) => Promise<void>; + + // โ”€โ”€โ”€ Widget Operations โ”€โ”€โ”€ + addWidget: (pageId: string, containerId: string, widgetId: string, props?: any, snippetId?: string) => Promise<string>; + removeWidget: (pageId: string, containerId: string, widgetInstanceId: string) => Promise<void>; + moveWidget: (pageId: string, widgetInstanceId: string, direction: Direction) => Promise<void>; + updateWidgetProps: (pageId: string, widgetInstanceId: string, props: Record<string, any>) => Promise<void>; + renameWidget: (pageId: string, widgetInstanceId: string, name: string) => Promise<void>; + + // โ”€โ”€โ”€ Nested Layout Management โ”€โ”€โ”€ + nestedLayouts: NestedLayoutManager; +} +``` + +--- + +## 11. Export Context + +```ts +interface ExportContext { + format: 'html' | 'email' | 'pdf' | 'amp'; + locale: string; + baseUrl: string; + /** Resolve asset URLs (images, etc.) */ + resolveAsset: (path: string) => string; + /** Current page context */ + page: { slug: string; title: string; locale: string }; +} +``` + +--- + +## File Map + +| Interface | Current Location | Status | +|-----------|-----------------|--------| +| `WidgetDefinition` | [src/lib/widgetRegistry.ts](../src/lib/widgetRegistry.ts) | Extend | +| `WidgetMetadata` | [src/lib/widgetRegistry.ts](../src/lib/widgetRegistry.ts) | Extend | +| `WidgetInstance` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Minor additions | +| `LayoutContainer` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Extend settings | +| `FlexibleContainer` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Stable | +| `PageLayout` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Add version/meta | +| `ConfigSchema` | inline in [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts) | Extract to types | +| `NestedLayoutManager` | โ€” | **New** | +| `WidgetLifecycleContext` | โ€” | **New** | +| `BaseWidgetProps` | implicit in [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) | **Extract** | +| Widget registrations | [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts) | Adopt new types | +| Container rendering | [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) | Formalize contract | +| Layout context | [src/modules/layout/LayoutContext.tsx](../src/modules/layout/LayoutContext.tsx) | Add `nestedLayouts` | + +--- + +## 12. State Management โ€” Escaping Callback Hell + +### The Problem + +The current system passes **13+ props** through every widget instance ([LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) lines 775-788): + +```tsx +<WidgetComponent + {...(widget.props || {})} + widgetInstanceId={widget.id} // identity + widgetDefId={widget.widgetId} // identity + isEditMode={isEditMode} // mode + onPropsChange={handlePropsChange} // callback + selectedWidgetId={selectedWidgetId} // state (global) + onSelectWidget={onSelectWidget} // callback + editingWidgetId={editingWidgetId} // state (global) + onEditWidget={onEditWidget} // callback + contextVariables={contextVariables} // data + pageContext={pageContext} // data + selectedContainerId={...} // state (global) + onSelectContainer={...} // callback +/> +``` + +Every nesting level (Container โ†’ Widget โ†’ TabsWidget โ†’ GenericCanvas โ†’ Container โ†’ Widget) re-spreads all callbacks. This causes: +- Re-render cascades (any selection change re-renders every widget) +- Stale closures in deeply nested effects +- Race conditions (the SYNC-BACK bug) +- Unmaintainable component signatures + +### Option A: Preact Signals + +Signals are **reactive primitives** that bypass React's render cycle. A signal write only re-renders components that directly read it. + +```ts +// layoutSignals.ts +import { signal, computed, effect } from '@preact/signals-react'; + +export const selectedWidgetId = signal<string | null>(null); +export const editingWidgetId = signal<string | null>(null); +export const loadedPages = signal(new Map<string, PageLayout>()); +export const hydratedIds = signal(new Set<string>()); + +// Derived โ€” no useEffect needed +export const currentLayout = computed(() => + loadedPages.value.get(currentPageId.value) +); + +// Replace SYNC-BACK with a simple effect +effect(() => { + const pages = loadedPages.value; + // Fires only when loadedPages actually changes โ€” no polling +}); +``` + +Widget component โ€” zero callback props: + +```tsx +const TabsWidget = ({ tabs, widgetInstanceId }) => { + const isSelected = selectedWidgetId.value === widgetInstanceId; + const handleClick = () => { selectedWidgetId.value = widgetInstanceId; }; + // No onSelectWidget, onPropsChange, etc. +}; +``` + +| Pros | Cons | +|------|------| +| Zero callbacks, zero prop drilling | New paradigm โ€” team adoption overhead | +| Surgical re-renders (only readers update) | Mutable state โ€” harder to debug than React DevTools | +| `effect()` replaces SYNC-BACK entirely | Suspense/concurrent mode not fully compatible | +| Tiny bundle (~1KB) | Signal chains harder to trace than component tree | + +**Reference projects using Signals:** +- [Preact Signals for React](https://github.com/preactjs/signals) โ€” Official adapter +- [Signia](https://github.com/tldraw/signia) โ€” tldraw's signal library (powers their canvas) +- [Solid.js](https://www.solidjs.com/) โ€” Framework built entirely on signals (inspiration, not direct use) + +--- + +### Option B: Zustand Store โญ recommended + +A thin external store replaces all callback+state props. Widgets subscribe to **slices** โ€” only re-render when their specific data changes. + +```ts +// useLayoutStore.ts +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; + +interface LayoutStore { + // โ”€โ”€โ”€ State โ”€โ”€โ”€ + loadedPages: Map<string, PageLayout>; + hydratedIds: Set<string>; + selectedWidgetId: string | null; + editingWidgetId: string | null; + selectedContainerId: string | null; + isEditMode: boolean; + + // โ”€โ”€โ”€ Actions (replace callbacks) โ”€โ”€โ”€ + selectWidget: (id: string | null) => void; + editWidget: (id: string | null) => void; + selectContainer: (id: string | null) => void; + updateWidgetProps: (pageId: string, widgetId: string, props: Record<string, any>) => Promise<void>; + hydrateLayout: (pageId: string, layout: PageLayout) => void; + saveLayout: (pageId: string) => Promise<void>; +} + +export const useLayoutStore = create<LayoutStore>()( + subscribeWithSelector((set, get) => ({ + loadedPages: new Map(), + hydratedIds: new Set(), + selectedWidgetId: null, + editingWidgetId: null, + selectedContainerId: null, + isEditMode: false, + + selectWidget: (id) => set({ selectedWidgetId: id }), + editWidget: (id) => set({ editingWidgetId: id }), + selectContainer: (id) => set({ selectedContainerId: id }), + + hydrateLayout: (pageId, layout) => { + const pages = new Map(get().loadedPages); + pages.set(pageId, layout); + const hydrated = new Set(get().hydratedIds); + hydrated.add(pageId); + set({ loadedPages: pages, hydratedIds: hydrated }); + }, + + updateWidgetProps: async (pageId, widgetId, props) => { + const pages = new Map(get().loadedPages); + const layout = pages.get(pageId); + if (!layout) return; + // ... mutate widget props in layout ... + layout.updatedAt = Date.now(); + pages.set(pageId, { ...layout }); + set({ loadedPages: pages }); + }, + + saveLayout: async (pageId) => { /* persist to backend */ }, + })) +); +``` + +Widget component โ€” **4 props instead of 18**: + +```tsx +const TabsWidget: React.FC<{ tabs: TabDef[]; widgetInstanceId: string }> = ({ tabs, widgetInstanceId }) => { + // Selector-based โ€” only re-renders when THIS widget's selection state changes + const isSelected = useLayoutStore(s => s.selectedWidgetId === widgetInstanceId); + const isEditMode = useLayoutStore(s => s.isEditMode); + const selectWidget = useLayoutStore(s => s.selectWidget); + const updateProps = useLayoutStore(s => s.updateWidgetProps); + + // Replace SYNC-BACK with subscribe (outside React render cycle) + useEffect(() => { + if (!isEditMode) return; + return useLayoutStore.subscribe( + s => s.loadedPages, + (pages) => { + // Only fires when loadedPages reference changes + tabs.forEach(t => { + const layout = pages.get(t.layoutId); + if (layout && isRealMutation(layout, t.layoutData)) { + updateProps(parentPageId, widgetInstanceId, { + tabs: tabs.map(tab => + tab.layoutId === t.layoutId ? { ...tab, layoutData: layout } : tab + ) + }); + } + }); + } + ); + }, [tabs, isEditMode]); +}; +``` + +Container passes only identity props: + +```tsx +// Before: 18 props +<WidgetComponent {...widget.props} widgetInstanceId={...} widgetDefId={...} + isEditMode={...} onPropsChange={...} selectedWidgetId={...} + onSelectWidget={...} editingWidgetId={...} onEditWidget={...} + contextVariables={...} pageContext={...} selectedContainerId={...} + onSelectContainer={...} /> + +// After: 4 props โ€” everything else comes from the store +<WidgetComponent {...widget.props} widgetInstanceId={widget.id} + widgetDefId={widget.widgetId} isEditMode={isEditMode} /> +``` + +| Pros | Cons | +|------|------| +| Familiar React patterns, tiny API surface | Still React re-renders (selector-gated) | +| `subscribeWithSelector` replaces SYNC-BACK | Map/Set equality needs careful handling | +| Works with Suspense + concurrent mode | Zustand middleware stack can grow complex | +| Incremental migration โ€” replace consumers one at a time | Extra dependency (~1.5KB) | +| Time-travel debugging with devtools middleware | โ€” | + +**Reference projects using Zustand:** +- [Zustand](https://github.com/pmndrs/zustand) โ€” Docs, recipes, middleware patterns +- [tldraw](https://github.com/tldraw/tldraw) โ€” Canvas editor with Zustand + Signia for state +- [Excalidraw](https://github.com/excalidraw/excalidraw) โ€” Whiteboard with similar widget/element model +- [React Flow](https://github.com/xyflow/xyflow) โ€” Node graph editor, Zustand-based state for nodes/edges +- [Plate.js](https://github.com/udecode/plate) โ€” Plugin-based editor with Zustand stores per plugin +- [BuilderIO](https://github.com/BuilderIO/builder) โ€” Visual page builder with widget registry patterns + +--- + +### Option C: Context + useReducer (zero dependencies) + +Keep React context but replace callbacks with actions: + +```ts +type LayoutAction = + | { type: 'SELECT_WIDGET'; widgetId: string | null; pageId?: string } + | { type: 'EDIT_WIDGET'; widgetId: string | null } + | { type: 'SELECT_CONTAINER'; containerId: string | null } + | { type: 'UPDATE_PROPS'; pageId: string; widgetId: string; props: Record<string, any> } + | { type: 'HYDRATE_LAYOUT'; pageId: string; layout: PageLayout }; + +// Split contexts to avoid re-render cascade +const LayoutStateContext = React.createContext<LayoutState>(null!); +const LayoutDispatchContext = React.createContext<React.Dispatch<LayoutAction>>(null!); + +// Widget uses dispatch โ€” stable reference, never changes +const dispatch = useLayoutDispatch(); +dispatch({ type: 'SELECT_WIDGET', widgetId: id }); +``` + +| Pros | Cons | +|------|------| +| Zero dependencies | Context still re-renders all consumers on state change | +| Standard React pattern | Must split into ~4 contexts to prevent cascade | +| Easy to type | No selector-based subscriptions (need `use-context-selector` polyfill) | +| โ€” | More boilerplate than Zustand | + +**Reference projects using Context+Reducer:** +- [use-context-selector](https://github.com/dai-shi/use-context-selector) โ€” Selector API for React Context (by Zustand's author) +- [Lexical](https://github.com/facebook/lexical) โ€” Facebook's editor, plugin-based with context commands +- [Gutenberg](https://github.com/WordPress/gutenberg) โ€” WordPress block editor, registry + Redux stores + +--- + +### Comparison + +| | Signals | Zustand โญ | Context+Reducer | +|---|---------|---------|-----------------| +| **Migration effort** | High (paradigm shift) | Medium (incremental) | Low (refactor) | +| **Re-renders** | Only signal readers | Only selector matches | All context consumers | +| **SYNC-BACK fix** | `effect()` โ€” built in | `subscribe()` โ€” external | Still needs guard | +| **Suspense compat** | โš ๏ธ Partial | โœ… Full | โœ… Full | +| **Debugging** | Signal graph | Redux DevTools | React DevTools | +| **Bundle** | ~1KB | ~1.5KB | 0KB | +| **Callback props removed** | All | All | All (via dispatch) | +| **Deep nesting** | โœ… Flat access | โœ… Flat access | โœ… Flat access | + +--- + +## 13. Plugin System โ€” Modify, Inject, Extend + +Plugins allow internal teams and third-party packages to modify existing widgets, inject new ones, extend container behavior, and hook into the layout lifecycle โ€” without touching core code. + +### Plugin Definition + +```ts +interface WidgetPlugin { + /** Unique plugin identifier (e.g. 'polymech:analytics', 'acme:custom-charts') */ + id: string; + /** Human-readable name */ + name: string; + /** Semver version */ + version: string; + /** Plugin priority โ€” higher priority plugins run first (default: 0) */ + priority?: number; + /** Required capabilities โ€” plugin won't load if missing */ + requires?: string[]; + + // โ”€โ”€โ”€ Registration โ”€โ”€โ”€ + /** Called once during app bootstrap โ€” register widgets, hooks, slots */ + setup: (api: PluginAPI) => void | Promise<void>; + /** Called on hot reload / plugin unload โ€” clean up */ + teardown?: () => void; +} +``` + +### Plugin API + +The `PluginAPI` is the only interface plugins interact with. It gates all access. + +```ts +interface PluginAPI { + // โ”€โ”€โ”€ Widget Registry โ”€โ”€โ”€ + /** Register a new widget */ + registerWidget: <P>(definition: WidgetDefinition<P>) => void; + /** Remove a widget from the registry */ + unregisterWidget: (widgetId: string) => void; + /** Modify an existing widget's definition (non-destructive merge) */ + modifyWidget: (widgetId: string, patch: Partial<WidgetDefinition>) => void; + /** Wrap an existing widget's component with a HOC */ + wrapWidget: (widgetId: string, wrapper: WidgetWrapper) => void; + /** Extend a widget's configSchema with additional fields */ + extendConfig: (widgetId: string, fields: Record<string, ConfigField>) => void; + + // โ”€โ”€โ”€ Hooks Pipeline โ”€โ”€โ”€ + /** Register a hook that runs at a specific lifecycle point */ + addHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void; + /** Remove a previously registered hook */ + removeHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void; + + // โ”€โ”€โ”€ Slot Injection โ”€โ”€โ”€ + /** Inject React content into named slots in the editor UI */ + injectSlot: (slotId: SlotId, component: React.ComponentType<SlotProps>) => void; + + // โ”€โ”€โ”€ Container Extensions โ”€โ”€โ”€ + /** Register a custom container type */ + registerContainerType: (type: string, renderer: ContainerRenderer) => void; + /** Add settings fields to existing container types */ + extendContainerSettings: (type: ContainerType, fields: Record<string, ConfigField>) => void; + + // โ”€โ”€โ”€ Store Access โ”€โ”€โ”€ + /** Read-only access to layout store (Zustand) */ + getStore: () => Readonly<LayoutStore>; + /** Subscribe to store changes */ + subscribe: <T>(selector: (state: LayoutStore) => T, callback: (value: T) => void) => () => void; +} +``` + +--- + +### Hook Pipeline + +Hooks are the primary extension mechanism. They form a **pipeline** โ€” each hook handler can inspect, modify, or short-circuit data flowing through the system. + +```ts +type HookName = + // Widget lifecycle + | 'widget:beforeRender' // Modify props before widget renders + | 'widget:afterRender' // Wrap rendered output (e.g. add analytics wrapper) + | 'widget:beforeSave' // Transform props before persisting + | 'widget:afterSave' // Side effects after save (analytics, sync, etc.) + | 'widget:beforeAdd' // Intercept widget addition (validation, defaults) + | 'widget:afterAdd' // Side effects after widget added + | 'widget:beforeRemove' // Confirmation, cleanup + | 'widget:afterRemove' // Side effects after removal + // Container lifecycle + | 'container:beforeRender' // Modify container before rendering children + | 'container:afterRender' // Wrap container output + | 'container:beforeSave' // Transform container data before persist + // Layout lifecycle + | 'layout:beforeHydrate' // Modify layout data during hydration + | 'layout:afterHydrate' // Side effects after hydration + | 'layout:beforeSave' // Final transform before full layout save + | 'layout:afterSave' // Side effects after save + // Editor + | 'editor:beforeDrop' // Validate/modify drag-drop operations + | 'editor:widgetPalette' // Filter/reorder palette widget list + | 'editor:contextMenu'; // Add items to right-click context menus + +interface HookContext { + pluginId: string; + pageId: string; + isEditMode: boolean; + store: Readonly<LayoutStore>; +} + +// Type-safe handler signatures per hook +type HookHandler<T extends HookName> = + T extends 'widget:beforeRender' + ? (props: Record<string, any>, widget: WidgetInstance, ctx: HookContext) => Record<string, any> + : T extends 'widget:afterRender' + ? (element: React.ReactElement, widget: WidgetInstance, ctx: HookContext) => React.ReactElement + : T extends 'widget:beforeAdd' + ? (widgetId: string, props: Record<string, any>, ctx: HookContext) => Record<string, any> | false + : T extends 'editor:widgetPalette' + ? (widgets: WidgetDefinition[], ctx: HookContext) => WidgetDefinition[] + : T extends 'editor:contextMenu' + ? (items: ContextMenuItem[], widget: WidgetInstance, ctx: HookContext) => ContextMenuItem[] + : (data: any, ctx: HookContext) => any; +``` + +#### Hook Execution Order + +Hooks run in **priority order** (higher first), then registration order within same priority: + +``` +Plugin A (priority: 10) โ†’ Plugin B (priority: 5) โ†’ Plugin C (priority: 0) +``` + +A hook handler can **short-circuit** by returning `false` (for `before*` hooks) or by not calling `next()` in an async pipeline. + +--- + +### Widget Modification + +Modify an existing widget without replacing it: + +```ts +// Plugin: Add "analytics" toggle to every ImageWidget +const analyticsPlugin: WidgetPlugin = { + id: 'polymech:analytics', + name: 'Analytics Tracking', + version: '1.0.0', + + setup(api) { + // 1. Add a config field to ImageWidget + api.extendConfig('image-widget', { + trackClicks: { + type: 'boolean', + label: 'Track Clicks', + default: false, + group: 'Analytics', + }, + trackingLabel: { + type: 'text', + label: 'Event Label', + showWhen: { field: 'trackClicks', equals: true }, + group: 'Analytics', + }, + }); + + // 2. Wrap the widget to inject click tracking + api.wrapWidget('image-widget', (WrappedComponent) => { + return function TrackedImage(props) { + const handleClick = () => { + if (props.trackClicks) { + analytics.track('image_click', { label: props.trackingLabel }); + } + }; + return ( + <div onClick={handleClick}> + <WrappedComponent {...props} /> + </div> + ); + }; + }); + } +}; +``` + +--- + +### Widget Injection + +Register entirely new widgets: + +```ts +const chartPlugin: WidgetPlugin = { + id: 'acme:charts', + name: 'Advanced Charts', + version: '2.0.0', + + setup(api) { + api.registerWidget({ + component: React.lazy(() => import('./AcmeBarChart')), + metadata: { + id: 'acme-bar-chart', + name: 'Bar Chart (Acme)', + category: 'chart', + description: 'Interactive bar chart with drill-down', + tags: ['chart', 'analytics', 'acme'], + defaultProps: { dataSource: '', orientation: 'vertical' }, + configSchema: { + dataSource: { type: 'text', label: 'Data Endpoint', required: true }, + orientation: { type: 'select', label: 'Orientation', options: [ + { value: 'vertical', label: 'Vertical' }, + { value: 'horizontal', label: 'Horizontal' }, + ]}, + }, + }, + }); + } +}; +``` + +--- + +### Widget Extension (Inheritance) + +Extend an existing widget to create a variant: + +```ts +setup(api) { + const baseImage = api.getStore()./* get widget def somehow */; + + // Create a "Hero Image" that extends ImageWidget with extra defaults + api.registerWidget({ + component: React.lazy(() => import('./HeroImage')), + metadata: { + id: 'hero-image', + name: 'Hero Image', + category: 'display', + description: 'Full-bleed hero image with overlay text', + tags: ['hero', 'image', 'banner'], + defaultProps: { + width: '100%', + height: '50vh', + objectFit: 'cover', + overlayText: '', + overlayPosition: 'center', + }, + configSchema: { + // Inherit image fields + add overlay + ...baseImageConfigSchema, + overlayText: { type: 'markdown', label: 'Overlay Text', group: 'Overlay' }, + overlayPosition: { type: 'select', label: 'Position', group: 'Overlay', options: [ + { value: 'top-left', label: 'Top Left' }, + { value: 'center', label: 'Center' }, + { value: 'bottom-right', label: 'Bottom Right' }, + ]}, + }, + }, + }); +} +``` + +--- + +### Slot Injection + +Plugins can inject UI into predefined **slots** in the editor chrome: + +```ts +type SlotId = + | 'editor:toolbar:start' // Left side of top toolbar + | 'editor:toolbar:end' // Right side of top toolbar + | 'editor:sidebar:top' // Above widget palette + | 'editor:sidebar:bottom' // Below widget palette + | 'editor:panel:right:top' // Top of right properties panel + | 'editor:panel:right:bottom' // Bottom of right properties panel + | 'widget:toolbar' // Per-widget toolbar (green bar) + | 'container:toolbar' // Per-container toolbar + | 'container:empty' // Shown inside empty containers + | 'page:header' // Above page content + | 'page:footer'; // Below page content + +interface SlotProps { + pageId: string; + isEditMode: boolean; + selectedWidgetId?: string | null; + selectedContainerId?: string | null; +} +``` + +Example: Inject a "Page Analytics" button into the toolbar: + +```ts +setup(api) { + api.injectSlot('editor:toolbar:end', ({ pageId }) => ( + <Button onClick={() => openAnalytics(pageId)} variant="ghost" size="sm"> + <BarChart3 className="h-4 w-4 mr-1" /> Analytics + </Button> + )); +} +``` + +--- + +### Container Plugins + +Register custom container types: + +```ts +setup(api) { + // Register a "carousel-container" that renders children in a swiper + api.registerContainerType('carousel-container', { + renderer: CarouselContainerRenderer, + editRenderer: CarouselContainerEditRenderer, + icon: GalleryHorizontal, + label: 'Carousel', + defaultSettings: { + autoplay: false, + interval: 5000, + showDots: true, + }, + settingsSchema: { + autoplay: { type: 'boolean', label: 'Autoplay' }, + interval: { type: 'number', label: 'Interval (ms)', showWhen: { field: 'autoplay', equals: true } }, + showDots: { type: 'boolean', label: 'Show Dots' }, + }, + }); +} +``` + +--- + +### Permission Gating + +Plugins can restrict widget availability based on user roles, page context, or feature flags: + +```ts +setup(api) { + // Filter palette based on user role + api.addHook('editor:widgetPalette', (widgets, ctx) => { + const user = ctx.store.user; + return widgets.filter(w => { + // Hide admin-only widgets from regular users + if (w.metadata.tags?.includes('admin-only') && user?.role !== 'admin') return false; + // Hide premium widgets from free-tier + if (w.metadata.tags?.includes('premium') && !user?.isPremium) return false; + return true; + }); + }); + + // Prevent dropping restricted widgets + api.addHook('editor:beforeDrop', (dropData, ctx) => { + if (isRestricted(dropData.widgetId, ctx.store.user)) { + toast.error('Upgrade required'); + return false; // Short-circuit โ€” block the drop + } + return dropData; + }); +} +``` + +--- + +### Plugin Lifecycle & Registration + +```ts +// pluginManager.ts +class PluginManager { + private plugins = new Map<string, WidgetPlugin>(); + private api: PluginAPI; + + constructor(registry: WidgetRegistry, store: LayoutStore) { + this.api = createPluginAPI(registry, store); + } + + async register(plugin: WidgetPlugin): Promise<void> { + // Validate requirements + if (plugin.requires?.some(dep => !this.plugins.has(dep))) { + throw new Error(`Plugin ${plugin.id} missing deps: ${plugin.requires}`); + } + // Run setup + await plugin.setup(this.api); + this.plugins.set(plugin.id, plugin); + } + + async unregister(pluginId: string): Promise<void> { + const plugin = this.plugins.get(pluginId); + plugin?.teardown?.(); + this.plugins.delete(pluginId); + } + + getPlugins(): WidgetPlugin[] { + return [...this.plugins.values()] + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + } +} + +// Bootstrap โ€” app entry +const pluginManager = new PluginManager(widgetRegistry, layoutStore); + +// Core widgets (always loaded) +await pluginManager.register(coreWidgetsPlugin); + +// Optional plugins (lazy loaded, feature-flagged) +if (features.analytics) { + const { analyticsPlugin } = await import('./plugins/analytics'); + await pluginManager.register(analyticsPlugin); +} + +// User-installed plugins (dynamic import from config) +for (const pluginUrl of userPluginManifest) { + const mod = await import(/* @vite-ignore */ pluginUrl); + await pluginManager.register(mod.default); +} +``` + +--- + +### Plugin Isolation & Safety + +| Concern | Strategy | +|---------|----------| +| **Performance** | Plugins that throw don't crash the editor โ€” wrap `setup()` in try/catch | +| **Conflicts** | Two plugins modifying the same widget โ€” resolved by priority order | +| **Memory** | `teardown()` must clean up subscriptions, timers, DOM listeners | +| **Security** | `PluginAPI` is a **capability-limited facade** โ€” no direct store mutation | +| **Versioning** | `requires` field enables dependency resolution at registration time | +| **HMR** | `unregister` + `register` cycle on hot reload (Vite-compatible) | + +--- + +### Current Registry โ†’ Plugin Bridge + +The existing [widgetRegistry](../src/lib/widgetRegistry.ts) already supports `register()` with overwrite semantics (line 28-32). Wrapping the existing `registerWidgets()` call ([src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts)) as a core plugin is a mechanical refactor: + +```ts +// Before +import { registerAllWidgets } from '@/lib/registerWidgets'; +registerAllWidgets(); + +// After +import { coreWidgetsPlugin } from '@/plugins/core-widgets'; +await pluginManager.register(coreWidgetsPlugin); +``` + +--- + +## 14. Unified Node Model โ€” Collapsing Widgets and Containers + +### Why the distinction is problematic + +The current system has **two separate type hierarchies**: + +``` +PageLayout + โ””โ”€โ”€ Container (grid | flex-container) โ† structural + โ”œโ”€โ”€ WidgetInstance (leaf) โ† content + โ””โ”€โ”€ Container (nested, recursive) โ† structural again + โ””โ”€โ”€ WidgetInstance ... +``` + +This works until a **widget needs to be a container** โ€” TabsWidget, AccordionWidget, LayoutContainerWidget all manage sub-layouts internally. They're widgets pretending to be containers, which creates: + +- The SYNC-BACK race condition ([nested-ex.md](./nested-ex.md)) +- Separate code paths for adding widgets vs adding containers +- `addWidget()` + `addPageContainer()` + `removeWidget()` + `removePageContainer()` โ€” doubled API surface +- Container rendering ([LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx)) vs widget rendering โ€” two renderers for one recursive tree +- Embedded `layoutData` in widget props โ€” a layout-within-a-layout, invisible to the parent tree + +### Current data shape (two types) + +```ts +// Container โ€” knows about grid/flex, owns widgets +interface LayoutContainer { + id: string; + type: 'container' | 'flex-container'; + columns: number; + gap: number; + widgets: WidgetInstance[]; // โ† flat child array + children: LayoutContainer[]; // โ† recursive containers only + settings?: ContainerSettings; +} + +// Widget โ€” leaf node, can't have children (officially) +interface WidgetInstance { + id: string; + widgetId: string; // registry key + props?: Record<string, any>; // may secretly contain layoutData! +} +``` + +### Proposed: Single Node type + +Every element in the tree โ€” containers, widgets, tab panes, rows โ€” is just a `Node`: + +```ts +interface Node { + id: string; + type: string; // 'flex' | 'grid' | 'image' | 'tabs' | 'tab-pane' | 'markdown' + props: Record<string, any>; // type-specific config + children: Node[]; // โ† replaces BOTH Container.widgets AND Container.children + parentId: string | null; + + // Layout hints (replaces Container-specific fields) + layout?: NodeLayout; + + // Constraints (from registry, not stored โ€” computed at runtime) + // constraints?: NodeConstraints; +} + +interface NodeLayout { + display: 'flex' | 'grid' | 'block' | 'none'; + columns?: number; + gap?: number; + rows?: RowDef[]; + direction?: 'row' | 'column'; + align?: 'stretch' | 'start' | 'center' | 'end'; +} + +interface NodeConstraints { + canHaveChildren: boolean; + allowedChildTypes?: string[]; + maxChildren?: number; + draggable: boolean; + deletable: boolean; +} +``` + +### What collapses + +| Before (two-type) | After (unified) | +|----|-----| +| `LayoutContainer` + `FlexibleContainer` + `WidgetInstance` | `Node` | +| `Container.widgets[]` | `Node.children[]` | +| `Container.children[]` (recursive containers) | `Node.children[]` (same array) | +| TabsWidget with embedded `layoutData` per tab | TabsNode with `children: [TabPaneNode, ...]` | +| SYNC-BACK effect | Gone โ€” children are part of the tree | +| `addWidget()` + `addPageContainer()` | `addChild(parentId, node)` | +| Container renderer + Widget renderer | One recursive renderer | +| `NestedLayoutManager` (ยง7) | Not needed โ€” nested layouts don't exist | + +### TabsWidget: Before vs After + +**Before** โ€” widget with embedded layouts, SYNC-BACK, hydration races: + +```ts +{ + id: 'w1', + widgetId: 'tabs-widget', + props: { + tabs: [ + { id: 'tab-1', label: 'About', layoutId: 'tab-layout-1', + layoutData: { containers: [{ widgets: [...] }] } }, // layout-in-a-layout + { id: 'tab-2', label: 'Contact', layoutId: 'tab-layout-2', + layoutData: { containers: [{ widgets: [...] }] } }, + ] + } +} +``` + +**After** โ€” node with children, zero indirection: + +```ts +{ + id: 'w1', + type: 'tabs', + props: { activeTab: 'tab-1', orientation: 'horizontal' }, + children: [ + { + id: 'tp1', type: 'tab-pane', + props: { label: 'About' }, + layout: { display: 'flex', direction: 'column', gap: 16 }, + children: [ + { id: 'n1', type: 'markdown', props: { content: '...' }, children: [] }, + { id: 'n2', type: 'image', props: { src: '...' }, children: [] }, + ] + }, + { + id: 'tp2', type: 'tab-pane', + props: { label: 'Contact' }, + layout: { display: 'flex', direction: 'column', gap: 16 }, + children: [ + { id: 'n3', type: 'contact-form', props: {}, children: [] }, + ] + }, + ] +} +``` + +No `layoutData`. No `loadedPages` for sub-layouts. No SYNC-BACK. The tree **is** the layout. + +### FlexibleContainer + PhotoCards: Before vs After + +A real-world page: a flex container with two rows โ€” row 1 has a hero image spanning full width, row 2 has three PhotoCards in a 1fr 1fr 1fr grid. + +**Before** โ€” [FlexibleContainerRenderer.tsx](../src/modules/layout/FlexibleContainerRenderer.tsx) / [FlexContainerView.tsx](../src/modules/layout/FlexContainerView.tsx) with [PhotoCard.tsx](../src/components/PhotoCard.tsx): + +```ts +// Page content stored in DB โ€” container + widgetInstances, cells addressed by rowId:column +{ + containers: [{ + id: 'fc1', + type: 'flex-container', + gap: 16, + rows: [ + { id: 'r1', columns: [{ width: 1, unit: 'fr' }], sizing: 'unconstrained' }, + { id: 'r2', columns: [{ width: 1, unit: 'fr' }, { width: 1, unit: 'fr' }, { width: 1, unit: 'fr' }], gap: 12 }, + ], + widgets: [ + // Hero image โ€” row 1, column 0 + { id: 'w1', widgetId: 'photo-card', rowId: 'r1', column: 0, order: 0, + props: { pictureId: 'p100', image: '/pics/hero.jpg', title: 'Hero', variant: 'feed', imageFit: 'cover' } }, + // Three gallery cards โ€” row 2, columns 0/1/2 + { id: 'w2', widgetId: 'photo-card', rowId: 'r2', column: 0, order: 0, + props: { pictureId: 'p101', image: '/pics/a.jpg', title: 'Sunset', variant: 'grid' } }, + { id: 'w3', widgetId: 'photo-card', rowId: 'r2', column: 1, order: 0, + props: { pictureId: 'p102', image: '/pics/b.jpg', title: 'Mountains', variant: 'grid' } }, + { id: 'w4', widgetId: 'photo-card', rowId: 'r2', column: 2, order: 0, + props: { pictureId: 'p103', image: '/pics/c.jpg', title: 'Ocean', variant: 'grid' } }, + ], + settings: { title: 'Gallery Section', showTitle: true }, + }] +} +``` + +The renderer ([FlexContainerView.tsx](../src/modules/layout/FlexContainerView.tsx) line 105โ€“124) groups widgets into a `Map<"rowId:colIdx", WidgetInstance[]>`, then renders per row with `columnsToGridTemplate()`. + +**After** โ€” Unified Node tree: + +```ts +{ + id: 'fc1', + type: 'flex', + props: { title: 'Gallery Section', showTitle: true }, + layout: { display: 'flex', direction: 'column', gap: 16 }, + children: [ + // Row 1 โ€” hero + { + id: 'r1', type: 'flex-row', + props: {}, + layout: { display: 'grid', columns: 1, gap: 16 }, + children: [ + { id: 'w1', type: 'photo-card', children: [], + props: { pictureId: 'p100', image: '/pics/hero.jpg', title: 'Hero', + variant: 'feed', imageFit: 'cover' } }, + ] + }, + // Row 2 โ€” three cards + { + id: 'r2', type: 'flex-row', + props: {}, + layout: { display: 'grid', columns: 3, gap: 12 }, + children: [ + { id: 'w2', type: 'photo-card', children: [], + props: { pictureId: 'p101', image: '/pics/a.jpg', title: 'Sunset', variant: 'grid' } }, + { id: 'w3', type: 'photo-card', children: [], + props: { pictureId: 'p102', image: '/pics/b.jpg', title: 'Mountains', variant: 'grid' } }, + { id: 'w4', type: 'photo-card', children: [], + props: { pictureId: 'p103', image: '/pics/c.jpg', title: 'Ocean', variant: 'grid' } }, + ] + }, + ] +} +``` + +Key differences: + +| Aspect | Before | After | +|--------|--------|-------| +| Widget placement | `widget.rowId` + `widget.column` indices | Position = order in `parent.children[]` | +| Row definition | Separate `rows[]` array on container | `flex-row` nodes as children | +| Column spec | `row.columns[{width, unit}]` | `layout.columns` + `layout.gap` on the row node | +| Grouping logic | `widgetsByCell` Map computation (line 105โ€“124) | None โ€” children are already grouped by parent | +| Adding a card | `addWidget(pageId, 'fc1', 'photo-card', { rowId: 'r2', column: 1 })` | `addChild('r2', photoCardNode, 1)` | +| Moving between rows | Update `widget.rowId` + `widget.column` + re-index | `moveNode('w2', 'r1', 0)` | + +The `flex-row` type renders as a CSS grid row: + +```tsx +// flex-row component โ€” trivial +function FlexRowRenderer({ layout, children }: { layout: NodeLayout; children: React.ReactNode }) { + return ( + <div + className="grid min-w-0 max-md:!grid-cols-1" + style={{ + gridTemplateColumns: `repeat(${layout?.columns ?? 1}, 1fr)`, + gap: `${layout?.gap ?? 16}px`, + }} + > + {children} + </div> + ); +} +``` + +The [PhotoCard](../src/components/PhotoCard.tsx) component itself doesn't change at all โ€” it receives the same props either way. The only difference is **how it gets placed** in the tree. + +### Registry: same interface, no type distinction + +```ts +// "Container" types +widgetRegistry.register({ + metadata: { id: 'flex', category: 'layout', name: 'Flex Container', ... }, + component: FlexRenderer, + constraints: { canHaveChildren: true, draggable: true }, +}); + +// "Widget" types โ€” same registry, same interface +widgetRegistry.register({ + metadata: { id: 'image', category: 'display', name: 'Image', ... }, + component: ImageWidget, + constraints: { canHaveChildren: false, draggable: true }, +}); + +// Nested container types +widgetRegistry.register({ + metadata: { id: 'tabs', category: 'layout', name: 'Tabs', ... }, + component: TabsWidget, + constraints: { canHaveChildren: true, allowedChildTypes: ['tab-pane'] }, +}); + +widgetRegistry.register({ + metadata: { id: 'tab-pane', category: 'layout', name: 'Tab Pane', ... }, + component: TabPaneRenderer, + constraints: { canHaveChildren: true, draggable: false, deletable: false }, +}); +``` + +### Single recursive renderer + +```tsx +function NodeRenderer({ node, depth = 0 }: { node: Node; depth?: number }) { + const def = widgetRegistry.get(node.type); + if (!def) return null; + + const Component = def.component; + const canNest = def.constraints?.canHaveChildren; + + return ( + <Component {...node.props} nodeId={node.id} layout={node.layout}> + {canNest && node.children.map(child => ( + <NodeRenderer key={child.id} node={child} depth={depth + 1} /> + ))} + </Component> + ); +} +``` + +Replaces the current split between [LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) (~800 lines) and widget rendering. + +### Store operations: unified + +```ts +// Before: separate APIs +addWidget(pageId, containerId, widgetId, props); +addPageContainer(pageId, containerType); +removeWidget(pageId, containerId, widgetInstanceId); +removePageContainer(pageId, containerId); +moveWidget(pageId, widgetInstanceId, direction); + +// After: one set of tree operations +addChild(parentId: string, node: Node, index?: number): void; +removeNode(nodeId: string): void; +moveNode(nodeId: string, newParentId: string, index?: number): void; +updateProps(nodeId: string, props: Partial<Record<string, any>>): void; +``` + +### PageLayout simplifies to + +```ts +interface PageLayout { + id: string; + name: string; + root: Node; // โ† single root node, replaces containers[] + version: string; + createdAt: number; + updatedAt: number; +} +``` + +### Migration path + +| Step | Effort | What changes | +|------|--------|-------------| +| 1. Define `Node` type alongside existing types | Low | Types only, no runtime change | +| 2. Write `toNodeTree()` / `fromNodeTree()` converters | Medium | Convert at load/save boundary | +| 3. Build `NodeRenderer` (recursive) | Medium | Replaces container + widget rendering | +| 4. Migrate store operations to tree ops | Medium | `addChild` / `removeNode` / `moveNode` | +| 5. Migrate stored layouts (DB) | High | One-time script to flatten all pages | +| 6. Remove Container types, SYNC-BACK, NestedLayoutManager | Low | Delete code ๐ŸŽ‰ | + +### Who did this + +| Project | Model | Notes | +|---------|-------|-------| +| [Gutenberg](https://github.com/WordPress/gutenberg) | Block โ†’ InnerBlocks | Flat blocks โ†’ recursive blocks. Same migration. | +| [Craft.js](https://github.com/prevwong/craft.js) | `Node` with `nodes` map | Flat node map + `linkedNodes` for nested canvases | +| [Puck](https://github.com/measuredco/puck) | `ComponentData` with `children` | Zones are named child slots in components | +| [tldraw](https://github.com/tldraw/tldraw) | `TLShape` with parent reference | Flat store, parent-child via `parentId` | +| [GrapesJS](https://github.com/GrapesJS/grapesjs) | `Component` with `components()` | Recursive component tree, no widget/container split | +| [Lexical](https://github.com/facebook/lexical) | `LexicalNode` with children | `ElementNode` can have children, `TextNode` can't | + +--- + +## 15. Arbitrary React Components โ€” The Four Levels + +The unified Node model (ยง14) maps `node.type` โ†’ registry โ†’ React component. +If the registry can resolve to *any* component, the system becomes a generic React renderer. + +### Level 1: Pre-registered (current) + +Component is in the bundle, registered at boot: + +```ts +widgetRegistry.register({ + metadata: { id: 'photo-card', name: 'Photo Card', category: 'display', ... }, + component: PhotoCard, + constraints: { canHaveChildren: false, draggable: true }, +}); +``` + +Type-safe, tree-shakeable, fast. Only components known at build time. + +### Level 2: Lazy-imported (code-split) + +Component is in the bundle but loaded on first use: + +```ts +widgetRegistry.register({ + metadata: { id: 'chart', name: 'Chart', category: 'data', ... }, + component: React.lazy(() => import('@/components/Chart')), + constraints: { canHaveChildren: false }, +}); +``` + +Already used for [FlexContainerEdit](../src/modules/layout/FlexibleContainerRenderer.tsx) (line 8). + +### Level 3: Plugin-registered (npm package, build-time) + +A plugin brings its own components โ€” installed via npm, part of the build: + +```ts +// @polymech/maps-plugin +const mapsPlugin: WidgetPlugin = { + id: 'maps', + version: '1.0.0', + setup(api) { + api.registerWidget({ + metadata: { id: 'google-map', name: 'Google Map', icon: MapPin, category: 'embed' }, + component: React.lazy(() => import('./GoogleMapWidget')), + constraints: { canHaveChildren: false }, + }); + } +}; +``` + +Safe โ€” code-reviewed, bundled, no runtime loading. + +### Level 4: Remote components (runtime, any URL) + +Load a React component from a URL at runtime โ€” not in the bundle, not known at build time. + +Node shape in the tree: + +```ts +{ + id: 'rc1', + type: '__remote', + props: { + url: 'https://cdn.example.com/widgets/weather@1.0.0/index.mjs', + componentName: 'WeatherWidget', + // Pass-through props: + city: 'Berlin', + units: 'metric', + }, + children: [] +} +``` + +The `__remote` meta-widget loads and renders the ESM module: + +```tsx +function RemoteComponent({ url, componentName, ...passThrough }: { + url: string; + componentName?: string; + [key: string]: any; +}) { + const [Component, setComponent] = useState<React.ComponentType | null>(null); + const [error, setError] = useState<Error | null>(null); + + useEffect(() => { + let cancelled = false; + import(/* webpackIgnore: true */ url) + .then(mod => { + if (!cancelled) { + const Comp = componentName ? mod[componentName] : mod.default; + setComponent(() => Comp); + } + }) + .catch(err => !cancelled && setError(err)); + return () => { cancelled = true; }; + }, [url, componentName]); + + if (error) return <div className="text-destructive p-2 text-sm">Failed: {error.message}</div>; + if (!Component) return <div className="animate-pulse p-4 bg-muted rounded" />; + + return ( + <ErrorBoundary fallback={<div className="p-2 text-destructive">Widget crashed</div>}> + <Component {...passThrough} /> + </ErrorBoundary> + ); +} +``` + +Register once at boot: + +```ts +widgetRegistry.register({ + metadata: { id: '__remote', name: 'Remote Component', category: 'advanced', icon: Globe }, + component: RemoteComponent, + constraints: { canHaveChildren: false }, +}); +``` + +### Loading strategies comparison + +| Approach | How | Shared deps | Isolation | Build coordination | +|----------|-----|-------------|-----------|-------------------| +| **Dynamic `import(url)`** | Native ESM, `webpackIgnore` | No โ€” remote bundles own React | None (same origin) | None | +| **Module Federation** (Webpack 5 / Vite) | `remoteEntry.js` manifest | Yes โ€” shared React, shared stores | Partial | Remote must build with matching config | +| **Import Maps** | `<script type="importmap">` + bare specifiers | Yes โ€” browser resolves to shared CDN copy | None | CDN versioning only | +| **Sandboxed iframe** | `<iframe src={url} sandbox>` + `postMessage` | No | Full | None | + +**Recommendation**: Start with dynamic `import()` (simplest). Upgrade to Module Federation when you need shared React instances across remotes. + +### Security model + +Remote components execute arbitrary JS in your page. Mitigations: + +| Threat | Mitigation | +|--------|-----------| +| XSS / DOM manipulation | CSP `script-src` allowlist, SRI hashes on known URLs | +| Props leaking auth tokens | Whitelist passable props per component, never forward `user` / `token` | +| Crashing the host | `ErrorBoundary` wrapping every `RemoteComponent` | +| Infinite loops / CPU | `requestIdleCallback` loading, Web Worker for heavy init | +| Access to store/context | Remote components get **only** `passThrough` props โ€” no store, no context | +| Malicious URLs | Admin-only permission to add `__remote` nodes (see below), URL allowlist in settings | + +### Permission gating + +```ts +// Only admins can add remote widgets +pluginManager.addHook('editor:widgetPalette', (widgets, { user }) => { + if (!user.roles.includes('admin')) { + return widgets.filter(w => w.metadata.id !== '__remote'); + } + return widgets; +}); + +// Allowlist for remote URLs +pluginManager.addHook('widget:beforeRender', (node, ctx) => { + if (node.type === '__remote') { + const allowedHosts = ctx.settings.remoteWidgetAllowlist || []; + const url = new URL(node.props.url); + if (!allowedHosts.includes(url.host)) { + return { ...node, props: { ...node.props, _blocked: true } }; + } + } + return node; +}); +``` + +### What this enables + +| Use case | How | +|----------|-----| +| **Widget marketplace** | Publish component as ESM โ†’ add URL to page | +| **User-built components** | "Paste your widget URL" in advanced settings | +| **A/B test variants** | Swap component URL without redeploying | +| **Micro-frontends** | Each team publishes widgets independently | +| **Dev preview** | Point URL at `http://localhost:3001/MyWidget.js` during development | +| **Third-party embeds** | Wrap any React lib (chart, form, map) as a widget without forking | + +### Authoring a remote widget + +A remote widget is just a standard ESM module that exports a React component: + +```tsx +// weather-widget/src/index.tsx +import React, { useState, useEffect } from 'react'; + +export function WeatherWidget({ city, units = 'metric' }: { + city: string; + units?: 'metric' | 'imperial'; +}) { + const [weather, setWeather] = useState(null); + + useEffect(() => { + fetch(`https://api.weather.example/${city}?units=${units}`) + .then(r => r.json()) + .then(setWeather); + }, [city, units]); + + if (!weather) return <div>Loading weather...</div>; + + return ( + <div className="p-4 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 text-white"> + <h3 className="font-bold">{city}</h3> + <p className="text-3xl">{weather.temp}ยฐ</p> + <p className="text-sm opacity-80">{weather.condition}</p> + </div> + ); +} + +export default WeatherWidget; +``` + +Build with Vite as a library: + +```ts +// weather-widget/vite.config.ts +export default defineConfig({ + build: { + lib: { + entry: 'src/index.tsx', + formats: ['es'], + fileName: 'index', + }, + rollupOptions: { + external: ['react', 'react-dom'], // Use host's React + }, + }, +}); +``` + +Deploy `dist/index.mjs` to CDN. Done. + +### Summary: The four levels + +| Level | When | Safety | Flexibility | +|-------|------|--------|-------------| +| 1. Pre-registered | Core widgets, always available | โ˜…โ˜…โ˜…โ˜…โ˜… | โ˜…โ˜… | +| 2. Lazy-imported | Heavy widgets, load-on-demand | โ˜…โ˜…โ˜…โ˜…โ˜… | โ˜…โ˜…โ˜… | +| 3. Plugin-registered | npm ecosystem, opt-in features | โ˜…โ˜…โ˜…โ˜… | โ˜…โ˜…โ˜…โ˜… | +| 4. Remote (runtime) | Marketplace, user-built, micro-frontend | โ˜…โ˜… | โ˜…โ˜…โ˜…โ˜…โ˜… | + +All four levels coexist โ€” the registry doesn't care how a component was loaded. A Node with `type: 'photo-card'` and a Node with `type: '__remote'` render through the same recursive `NodeRenderer` (ยง14). + +--- + +## References + +### Libraries + +| Library | What it is | Relevance | +|---------|-----------|-----------| +| [Zustand](https://github.com/pmndrs/zustand) | Minimal state management for React | Recommended store replacement | +| [Preact Signals](https://github.com/preactjs/signals) | Fine-grained reactive primitives | Alternative reactive model | +| [Signia](https://github.com/tldraw/signia) | Signals library from tldraw team | Canvas-optimized signals | +| [Immer](https://github.com/immerjs/immer) | Immutable state via mutable API | Simplifies nested layout mutations | +| [use-context-selector](https://github.com/dai-shi/use-context-selector) | Selector API for React Context | Zero-dep context optimization | +| [dnd-kit](https://github.com/clauderic/dnd-kit) | Drag-and-drop toolkit | Already used โ€” store integration patterns | +| [Tapable](https://github.com/webpack/tapable) | Webpack's hook/plugin system | Pipeline-based hook architecture | +| [Hookable](https://github.com/unjs/hookable) | Lightweight async hook system (UnJS) | Simpler alternative to Tapable | + +### Reference Projects (widget/layout builder architectures) + +| Project | Architecture | What to study | +|---------|-------------|---------------| +| [tldraw](https://github.com/tldraw/tldraw) | Signals (Signia) + Zustand | Canvas state, shape lifecycle, nested shape trees | +| [Excalidraw](https://github.com/excalidraw/excalidraw) | Zustand + custom store | Element model, collaborative state, undo/redo | +| [React Flow](https://github.com/xyflow/xyflow) | Zustand store per instance | Node/edge registry, nested sub-flows, store slices | +| [Plate.js](https://github.com/udecode/plate) | Zustand + plugin stores | **Plugin architecture** โ€” per-plugin state, createPlatePlugin() | +| [Gutenberg](https://github.com/WordPress/gutenberg) | Redux + registry pattern | **Block extensions** โ€” InnerBlocks, block filters, slot fills | +| [Builder.io](https://github.com/BuilderIO/builder) | Zustand + Mitosis | Visual widget builder, config schema, drag-drop | +| [GrapesJS](https://github.com/GrapesJS/grapesjs) | Backbone โ†’ events | **Plugin system** โ€” component types, trait system, editor hooks | +| [Craft.js](https://github.com/prevwong/craft.js) | Context + custom store | **Resolver pattern** โ€” plugin components โ†’ node tree | +| [Puck](https://github.com/measuredco/puck) | React + custom state | **Component config** โ€” external component registration, drop zones | +| [Lexical](https://github.com/facebook/lexical) | Command bus + context | **Plugin architecture** โ€” registerCommand, node transforms | +| [Strapi](https://github.com/strapi/strapi) | Plugin registries | **Admin panel plugins** โ€” registerField, injectSlot pattern | +| [Sanity.io](https://github.com/sanity-io/sanity) | Plugin chains | **Schema extensions** โ€” definePlugin(), document actions | +| [Payload CMS](https://github.com/payloadcms/payload) | Config-driven plugins | **Field plugins** โ€” hooks.beforeChange, access control | + +### Articles & Patterns + +| Title | Topic | +|-------|-------| +| [Zustand: Bear Necessities of State Management](https://tkdodo.eu/blog/working-with-zustand) | Zustand best practices by TkDodo | +| [Signals vs React State](https://preactjs.com/blog/introducing-signals/) | Official Preact comparison | +| [The Case for Signals in JavaScript](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) | Ryan Carniato (Solid.js creator) | +| [React Context Performance](https://blog.isquaredsoftware.com/2021/01/context-redux-differences/) | Mark Erikson on context vs external stores | +| [Building a Page Builder](https://www.builder.io/blog/build-your-own-visual-builder) | Builder.io's architecture decisions | +| [Designing Plugin Systems](https://css-tricks.com/designing-a-javascript-plugin-system/) | General plugin architecture patterns | +| [Gutenberg Block Filters](https://developer.wordpress.org/block-editor/reference-guides/filters/) | WordPress block extension API | +| [GrapesJS Plugin API](https://grapesjs.com/docs/modules/Plugins.html) | Visual editor plugin registration | +| [Lexical Plugin Architecture](https://lexical.dev/docs/concepts/plugins) | Command-based plugin system | +| [Plate.js Plugin Guide](https://platejs.org/docs/plugin) | createPlatePlugin() pattern | diff --git a/packages/ui/shared/package.json b/packages/ui/shared/package.json index f0d1f136..d7dc6153 100644 --- a/packages/ui/shared/package.json +++ b/packages/ui/shared/package.json @@ -5,7 +5,8 @@ "type": "module", "types": "dist/index.d.ts", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./config": "./dist/config/index.js" }, "scripts": { "build": "tsc", diff --git a/packages/ui/shared/src/competitors/schemas.ts b/packages/ui/shared/src/competitors/schemas.ts new file mode 100644 index 00000000..57b0dd03 --- /dev/null +++ b/packages/ui/shared/src/competitors/schemas.ts @@ -0,0 +1,332 @@ +import { z } from 'zod/v4' + +import { ZodMetaMap } from '../map.js'; + +import { extendZodWithOpenApi } from '@hono/zod-openapi'; + +extendZodWithOpenApi(z); + +export const CompetitorSchema = z.object({ + place_id: z.string(), + title: z.string(), + description: z.string().optional().nullable(), + address: z.string().optional().nullable(), + gps_coordinates: z.object({ + latitude: z.number(), + longitude: z.number(), + }).optional().nullable(), + phone: z.string().optional().nullable(), + website: z.string().optional().nullable(), + operating_hours: z.record(z.string(), z.any()).optional().nullable(), + thumbnail: z.string().optional().nullable(), + types: z.array(z.string()).optional().nullable(), + continent: z.string().optional().nullable(), + country: z.string().optional().nullable(), + city: z.string().optional().nullable(), + updated_at: z.string().optional().nullable(), + raw_data: z.record(z.string(), z.any()).optional().nullable() +}) + +// Shared schemas +export const GpsCoordinatesSchema = z.object({ + latitude: z.number(), + longitude: z.number() +}) + +export const OperatingHoursSchema = z.object({ + sunday: z.string(), + monday: z.string(), + tuesday: z.string(), + wednesday: z.string(), + thursday: z.string(), + friday: z.string(), + saturday: z.string() +}) + +// Extension schemas +export const AccessibilityExtensionSchema = z.object({ + accessibility: z.array(z.string()) +}) + +export const CrowdExtensionSchema = z.object({ + crowd: z.array(z.string()) +}) + +export const ExtensionsSchema = z.array( + z.union([AccessibilityExtensionSchema, CrowdExtensionSchema]) +) + +// Locality info schemas +export const AdministrativeAreaFullSchema = z.object({ + name: z.string(), + description: z.string(), + isoName: z.string(), + order: z.number(), + adminLevel: z.number(), + isoCode: z.string(), + wikidataId: z.string(), + geonameId: z.number() +}) + +export const AdministrativeAreaMinimalSchema = z.object({ + name: z.string(), + order: z.number(), + adminLevel: z.number() +}) + +export const AdministrativeAreaWithWikidataSchema = z.object({ + name: z.string(), + description: z.string(), + order: z.number(), + adminLevel: z.number(), + wikidataId: z.string() +}) + +export const AdministrativeAreaWithGeonameSchema = z.object({ + name: z.string(), + description: z.string(), + order: z.number(), + adminLevel: z.number(), + wikidataId: z.string(), + geonameId: z.number() +}) + +export const InformativeAreaFullSchema = z.object({ + name: z.string(), + description: z.string(), + isoName: z.string(), + order: z.number(), + isoCode: z.string(), + wikidataId: z.string(), + geonameId: z.number() +}) + +export const InformativeAreaBasicSchema = z.object({ + name: z.string(), + description: z.string(), + order: z.number() +}) + +export const InformativeAreaWithGeonameSchema = z.object({ + name: z.string(), + description: z.string(), + order: z.number(), + wikidataId: z.string(), + geonameId: z.number() +}) + +export const InformativeAreaWithWikidataSchema = z.object({ + name: z.string(), + description: z.string(), + order: z.number(), + wikidataId: z.string() +}) + +export const InformativeAreaMinimalSchema = z.object({ + name: z.string(), + order: z.number() +}) + +export const LocalityInfoSchema = z.object({ + administrative: z.array( + z.union([ + AdministrativeAreaFullSchema, + AdministrativeAreaMinimalSchema, + AdministrativeAreaWithWikidataSchema, + AdministrativeAreaWithGeonameSchema + ]) + ), + informative: z.array( + z.union([ + InformativeAreaFullSchema, + InformativeAreaBasicSchema, + InformativeAreaWithGeonameSchema, + InformativeAreaWithWikidataSchema, + InformativeAreaMinimalSchema + ]) + ) +}) + +// Geo schema +export const GeoSchema = z.object({ + latitude: z.number(), + longitude: z.number(), + localityLanguageRequested: z.string(), + continent: z.string(), + continentCode: z.string(), + countryName: z.string(), + countryCode: z.string(), + principalSubdivision: z.string(), + principalSubdivisionCode: z.string(), + city: z.string(), + locality: z.string(), + postcode: z.string(), + plusCode: z.string(), + localityInfo: LocalityInfoSchema +}) + +// Google Media schemas +export const SearchMetadataSchema = z.object({ + id: z.string(), + status: z.string(), + json_endpoint: z.string(), + created_at: z.string(), + processed_at: z.string(), + google_maps_photos_url: z.string(), + raw_html_file: z.string(), + prettify_html_file: z.string(), + total_time_taken: z.number() +}) + +export const SearchParametersSchema = z.object({ + engine: z.string(), + data_id: z.string(), + hl: z.string() +}) + +export const CategorySchema = z.object({ + title: z.string(), + id: z.string() +}) + +export const PhotoSchema = z.object({ + thumbnail: z.string(), + image: z.string(), + photo_meta_serpapi_link: z.string() +}) + +export const SerpapiPaginationSchema = z.object({ + next: z.string(), + next_page_token: z.string() +}) + +export const GoogleMediaSchema = z.object({ + search_metadata: SearchMetadataSchema, + search_parameters: SearchParametersSchema, + categories: z.array(CategorySchema), + photos: z.array(PhotoSchema), + serpapi_pagination: SerpapiPaginationSchema +}) + +// Raw data schema +export const LocationSchema = z.object({ + position: z.number(), + title: z.string(), + description: z.string(), + place_id: z.string(), + data_id: z.string(), + data_cid: z.string(), + reviews_link: z.string(), + photos_link: z.string(), + gps_coordinates: GpsCoordinatesSchema, + place_id_search: z.string(), + provider_id: z.string(), + type: z.string(), + types: z.array(z.string()), + type_id: z.string(), + type_ids: z.array(z.string()), + address: z.string(), + open_state: z.string(), + hours: z.string(), + operating_hours: OperatingHoursSchema, + phone: z.string(), + website: z.string(), + extensions: ExtensionsSchema, + thumbnail: z.string(), + serpapi_thumbnail: z.string(), + page: z.number(), + geo: GeoSchema, + meta: z.record(z.string(), z.any()), + google_media: GoogleMediaSchema +}).partial() + +// Main CompetitorSchemaFull +export const CompetitorSchemaFull = z.object({ + place_id: z.string(), + title: z.string(), + address: z.string().optional().nullable(), + gps_coordinates: GpsCoordinatesSchema.optional().nullable(), + phone: z.string().optional().nullable(), + website: z.string().optional().nullable(), + operating_hours: OperatingHoursSchema.optional().nullable(), + thumbnail: z.string().optional().nullable(), + types: z.array(z.string()).optional().nullable(), + raw_data: LocationSchema.optional().nullable(), + continent: z.string().optional().nullable(), + country: z.string().optional().nullable(), + city: z.string().optional().nullable(), + updated_at: z.string().optional().nullable() +}) + + +export const CompetitorResponseSchema = z.object({ + message: z.string(), + data: z.array(CompetitorSchemaFull).optional(), +}) + +export const CompetitorDetailResponseSchema = z.object({ + message: z.string(), + data: CompetitorSchemaFull.optional(), +}) + +export type Competitor = z.infer<typeof CompetitorSchema>; +export type CompetitorResponse = z.infer<typeof CompetitorResponseSchema>; +export type CompetitorDetailResponse = z.infer<typeof CompetitorDetailResponseSchema>; +export type CompetitorFull = z.infer<typeof CompetitorSchemaFull>; + + +export type OptionsSchemaMeta = Record<string, unknown> + +let schemaMap: ZodMetaMap<OptionsSchemaMeta>; + +export const CompetitorRequestSchemaMap = () => { + schemaMap = ZodMetaMap.create<OptionsSchemaMeta>() + schemaMap.add( + 'location', + z.string().default('dresden, germany') as any + , { 'ui:group': 'Search Parameters' }) + .add( + 'query', + z.string().default('plastichub') as any + , { 'ui:group': 'Search Parameters' }) + .add( + 'filterCity', + z.string().optional() as any + , { 'ui:group': 'Filters' }) + .add( + 'filterContinent', + z.string().optional() as any + , { 'ui:group': 'Filters' }) + .add( + 'filterCountry', + z.string().optional() as any + , { 'ui:group': 'Filters' }) + .add( + 'filterType', + z.string().optional() as any + , { 'ui:group': 'Filters' }) + .add( + 'excludedTypes', + z.array(z.string()).optional() as any + , { 'ui:group': 'Filters' }) + .add( + 'concurrency', + z.string().optional().default('5') as any + , { 'ui:group': 'Settings' }) + .add( + 'refresh', + z.boolean().optional().default(false) as any + , { 'ui:group': 'Settings' }) + .add( + 'limit', + z.string().optional().default('250') as any + , { 'ui:group': 'Settings' }) + + return schemaMap; +} + +export const CompetitorRequestSchema = CompetitorRequestSchemaMap().root() as any; +export const CompetitorUISchema = CompetitorRequestSchemaMap().getUISchema(); +export type LocationType = z.infer<typeof LocationSchema>; +export type CompetitorRequest = z.infer<typeof CompetitorRequestSchema>; + diff --git a/packages/ui/shared/src/config/config.d.ts b/packages/ui/shared/src/config/config.d.ts index f17460a5..a33351f6 100644 --- a/packages/ui/shared/src/config/config.d.ts +++ b/packages/ui/shared/src/config/config.d.ts @@ -3,13 +3,15 @@ export interface AppConfig { i18n: I18N; cad: Cad; core: Core; - footer_left: FooterLeft[]; + footer_left: FooterEmail[]; + footer_email: FooterEmail[]; footer_right: any[]; settings: Settings; params: Params; navigation: Navigation; navigation_button: NavigationButton; ecommerce: Ecommerce; + impressum: Impressum; metadata: Metadata; shopify: Shopify; pages: Pages; @@ -71,7 +73,7 @@ export interface Ecommerce { currencyCode: string; } -export interface FooterLeft { +export interface FooterEmail { href: string; text: string; } @@ -83,6 +85,17 @@ export interface I18N { asset_path: string; } +export interface Impressum { + company: string; + representative: string; + address: string; + email: string; + phone: string; + vat_id: string; + register: string; + responsible_content: string; +} + export interface Metadata { country: string; city: string; @@ -95,7 +108,7 @@ export interface Metadata { } export interface Navigation { - top: FooterLeft[]; + top: FooterEmail[]; } export interface NavigationButton { @@ -201,6 +214,7 @@ export interface Collections { export interface Site { title: string; base_url: string; + canonical_url: string; description: string; base_path: string; trailing_slash: boolean; diff --git a/packages/ui/shared/src/config/config.schema.ts b/packages/ui/shared/src/config/config.schema.ts index 41e009e7..ff2c8c9e 100644 --- a/packages/ui/shared/src/config/config.schema.ts +++ b/packages/ui/shared/src/config/config.schema.ts @@ -31,7 +31,7 @@ export const coreSchema = z.object({ osr_root: z.string(), }); -export const footerLeftSchema = z.object({ +export const footerEmailSchema = z.object({ href: z.string(), text: z.string(), }); @@ -50,7 +50,7 @@ export const paramsSchema = z.object({ }); export const navigationSchema = z.object({ - top: z.array(footerLeftSchema), + top: z.array(footerEmailSchema), }); export const navigationButtonSchema = z.object({ @@ -65,6 +65,17 @@ export const ecommerceSchema = z.object({ currencyCode: z.string(), }); +export const impressumSchema = z.object({ + company: z.string(), + representative: z.string(), + address: z.string(), + email: z.string(), + phone: z.string(), + vat_id: z.string(), + register: z.string(), + responsible_content: z.string(), +}); + export const metadataSchema = z.object({ country: z.string(), city: z.string(), @@ -154,6 +165,7 @@ export const imageSchema = z.object({ export const siteSchema = z.object({ title: z.string(), base_url: z.string(), + canonical_url: z.string(), description: z.string(), base_path: z.string(), trailing_slash: z.boolean(), @@ -202,13 +214,15 @@ export const appConfigSchema = z.object({ i18n: i18nSchema, cad: cadSchema, core: coreSchema, - footer_left: z.array(footerLeftSchema), + footer_left: z.array(footerEmailSchema), + footer_email: z.array(footerEmailSchema), footer_right: z.array(z.any()), settings: settingsSchema, params: paramsSchema, navigation: navigationSchema, navigation_button: navigationButtonSchema, ecommerce: ecommerceSchema, + impressum: impressumSchema, metadata: metadataSchema, shopify: shopifySchema, pages: pagesSchema, diff --git a/packages/ui/shared/src/index.ts b/packages/ui/shared/src/index.ts index 0b44c341..05ae79f7 100644 --- a/packages/ui/shared/src/index.ts +++ b/packages/ui/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './ui/schemas.js'; export * from './ui/page-iterator.js'; - +export * from './competitors/schemas.js'; +export * from './config/config.schema.js'; diff --git a/packages/ui/shared/src/ui/schemas.ts b/packages/ui/shared/src/ui/schemas.ts index f0582768..2d04761c 100644 --- a/packages/ui/shared/src/ui/schemas.ts +++ b/packages/ui/shared/src/ui/schemas.ts @@ -17,7 +17,9 @@ export const WidgetTypeSchema = z.enum([ 'page-card', 'markdown-text', 'layout-container-widget', - 'file-browser' + 'file-browser', + 'video-banner', + 'menu-widget' ]); export type WidgetType = z.infer<typeof WidgetTypeSchema>; @@ -59,6 +61,10 @@ export const PhotoCardWidgetSchema = z.object({ postId: z.string().nullable().optional().default(null), showHeader: z.boolean().default(true), showFooter: z.boolean().default(true), + showAuthor: z.boolean().default(true), + showActions: z.boolean().default(true), + showTitle: z.boolean().default(true), + showDescription: z.boolean().default(true), contentDisplay: ContentDisplaySchema.default('below'), imageFit: ImageFitSchema.default('cover'), variables: WidgetVariablesSchema, @@ -72,6 +78,7 @@ export type PhotoCardWidgetProps = z.infer<typeof PhotoCardWidgetSchema>; // ========================================= export const PhotoGridWidgetSchema = z.object({ pictureIds: z.array(z.string()).default([]), + columns: z.string().default('auto'), variables: WidgetVariablesSchema, }); @@ -112,6 +119,9 @@ export const GalleryWidgetSchema = z.object({ thumbnailsOrientation: z.enum(['horizontal', 'vertical']).default('horizontal'), zoomEnabled: z.boolean().default(false), showVersions: z.boolean().default(false), + showTitle: z.boolean().default(false), + showDescription: z.boolean().default(false), + autoPlayVideos: z.boolean().default(false), thumbnailsClassName: z.string().optional().default(''), variables: WidgetVariablesSchema, }); @@ -126,6 +136,8 @@ export const PageCardWidgetSchema = z.object({ pageId: z.string().nullable().default(null), showHeader: z.boolean().default(true), showFooter: z.boolean().default(true), + showAuthor: z.boolean().default(true), + showActions: z.boolean().default(true), contentDisplay: ContentDisplaySchema.default('below'), variables: WidgetVariablesSchema, }); @@ -168,6 +180,8 @@ export const FileBrowserWidgetSchema = z.object({ mode: z.enum(['simple', 'advanced']).default('simple'), viewMode: z.enum(['list', 'thumbs', 'tree']).default('list'), sortBy: z.enum(['name', 'ext', 'date', 'type']).default('name'), + splitSizeHorizontal: z.array(z.number()).optional(), + splitSizeVertical: z.array(z.number()).optional(), showToolbar: z.boolean().default(true), canChangeMount: z.boolean().default(true), allowFileViewer: z.boolean().default(true), @@ -175,12 +189,99 @@ export const FileBrowserWidgetSchema = z.object({ allowDownload: z.boolean().default(true), allowPreview: z.boolean().default(false), jail: z.boolean().default(false), + minHeight: z.string().optional().default('600px'), + showStatusBar: z.boolean().optional().default(true), + initialFile: z.string().optional(), + searchQuery: z.string().optional(), variables: WidgetVariablesSchema, }); export type FileBrowserWidgetProps = z.infer<typeof FileBrowserWidgetSchema>; +// ========================================= +// Video Banner Widget +// ========================================= + +export const VideoBannerButtonSchema = z.object({ + id: z.string(), + label: z.string(), + targetPageId: z.string().optional(), + targetPageSlug: z.string().optional(), + targetPageOwner: z.string().optional(), + href: z.string().optional(), +}); + +export const VideoBannerWidgetSchema = z.object({ + videoId: z.string().nullable().default(null), + posterImageId: z.string().nullable().default(null), + backgroundImageId: z.string().nullable().default(null), + heading: z.string().default(''), + description: z.string().default(''), + minHeight: z.number().default(500), + overlayOpacity: z.enum(['light', 'medium', 'dark']).default('medium'), + objectFit: ImageFitSchema.default('cover'), + buttons: z.array(VideoBannerButtonSchema).default([]), + variables: WidgetVariablesSchema, +}); + +export type VideoBannerWidgetProps = z.infer<typeof VideoBannerWidgetSchema>; + + +// ========================================= +// Menu Widget +// ========================================= + +export const MenuItemTypeSchema = z.enum(['custom', 'page', 'category', 'file']); + +export const MenuItemSchema = z.object({ + id: z.string(), + label: z.string(), + type: MenuItemTypeSchema, + href: z.string().optional(), + targetPageId: z.string().optional(), + targetPageSlug: z.string().optional(), + targetPageOwner: z.string().optional(), + categoryId: z.string().optional(), + categorySlug: z.string().optional(), + fileMount: z.string().optional(), + filePath: z.string().optional(), + openInNewTab: z.boolean().default(false), +}); + +export const MenuWidgetSizeSchema = z.enum(['sm', 'md', 'lg', 'xl']); + +export const MenuWidgetAlignSchema = z.enum(['left', 'center', 'right']); + +export const MenuWidgetVariantSchema = z.enum(['plain', 'bar', 'glass', 'pill', 'underline']); + +export const MenuWidgetSpacingSchema = z.enum(['none', 'xs', 'sm', 'md', 'lg', 'xl']); + +export const MenuWidgetBgSchema = z.enum([ + 'none', 'custom', + 'muted', 'dark', 'primary', 'accent', + 'gradient-primary', 'gradient-dark', 'gradient-ocean', + 'gradient-sunset', 'gradient-forest', 'gradient-brand', +]); + +export const MenuWidgetSchema = z.object({ + items: z.array(MenuItemSchema).default([]), + orientation: z.enum(['horizontal', 'vertical']).default('horizontal'), + size: MenuWidgetSizeSchema.default('md'), + align: MenuWidgetAlignSchema.default('left'), + variant: MenuWidgetVariantSchema.default('plain'), + padding: MenuWidgetSpacingSchema.default('none'), + margin: MenuWidgetSpacingSchema.default('none'), + bg: MenuWidgetBgSchema.default('none'), + bgFrom: z.string().default('#3b82f6'), + bgTo: z.string().default('#8b5cf6'), + variables: WidgetVariablesSchema, +}); + +export type MenuItem = z.infer<typeof MenuItemSchema>; +export type MenuWidgetProps = z.infer<typeof MenuWidgetSchema>; + + // ========================================= // Union of All Widget Props (Optional Helper) // ========================================= @@ -241,56 +342,10 @@ export const LayoutContainerSchema: z.ZodType<LayoutContainer> = z.lazy(() => z. settings: ContainerSettingsSchema.optional() })); -// ========================================= -// Flexible Container (Row-based layout) -// ========================================= - -export const ColumnDefSchema = z.object({ - width: z.number().default(1), - unit: z.enum(['fr', 'px', 'rem', '%']).default('fr'), - minWidth: z.number().optional(), -}); - -export type ColumnDef = z.infer<typeof ColumnDefSchema>; - -export const RowDefSchema = z.object({ - id: z.string(), - columns: z.array(ColumnDefSchema).default([{ width: 1, unit: 'fr' }]), - gap: z.number().optional(), - sizing: z.enum(['constrained', 'unconstrained']).optional().default('constrained'), - cellAlignments: z.array(z.enum(['stretch', 'start', 'center', 'end'])).optional(), -}); - -export type RowDef = z.infer<typeof RowDefSchema>; - -export type FlexibleContainer = { - id: string; - type: 'flex-container'; - rows: RowDef[]; - widgets: WidgetInstance[]; - gap: number; - order?: number; - settings?: ContainerSettings; -}; - -export const FlexibleContainerSchema: z.ZodType<FlexibleContainer> = z.lazy(() => z.object({ - id: z.string(), - type: z.literal('flex-container'), - rows: z.array(RowDefSchema), - widgets: z.array(WidgetInstanceSchema), - gap: z.number().default(16), - order: z.number().optional(), - settings: ContainerSettingsSchema.optional() -})); - -// Union of all container types -export type AnyContainer = LayoutContainer | FlexibleContainer; -export const AnyContainerSchema = z.union([LayoutContainerSchema, FlexibleContainerSchema]); - export const PageLayoutSchema = z.object({ id: z.string(), name: z.string(), - containers: z.array(AnyContainerSchema), + containers: z.array(LayoutContainerSchema), createdAt: z.number(), updatedAt: z.number(), loadedBundles: z.array(z.string()).optional(), diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 1b97f869..2a1a7d84 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -5,8 +5,8 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@/lib/queryClient"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; -import { AuthProvider } from "@/hooks/useAuth"; -import { PostNavigationProvider } from "@/contexts/PostNavigationContext"; +import { AuthProvider, useAuth } from "@/hooks/useAuth"; + import { OrganizationProvider } from "@/contexts/OrganizationContext"; import { LogProvider } from "@/contexts/LogContext"; @@ -15,8 +15,10 @@ import { ProfilesProvider } from "@/contexts/ProfilesContext"; import { WebSocketProvider } from "@/contexts/WS_Socket"; import { registerAllWidgets } from "@/lib/registerWidgets"; import TopNavigation from "@/components/TopNavigation"; -import GlobalDragDrop from "@/components/GlobalDragDrop"; +import Footer from "@/components/Footer"; +const GlobalDragDrop = React.lazy(() => import("@/components/GlobalDragDrop")); import { DragDropProvider } from "@/contexts/DragDropContext"; +import { useAppStore } from "@/store/appStore"; // Register all widgets on app boot registerAllWidgets(); @@ -27,23 +29,14 @@ import Auth from "./pages/Auth"; const UpdatePassword = React.lazy(() => import("./pages/UpdatePassword")); import Profile from "./pages/Profile"; -const Post = React.lazy(() => import("./pages/Post")); +const Post = React.lazy(() => import("./modules/posts/PostPage")); + import UserProfile from "./pages/UserProfile"; -import UserCollections from "./pages/UserCollections"; -const Collections = React.lazy(() => import("./pages/Collections")); -import NewCollection from "./pages/NewCollection"; - -const UserPage = React.lazy(() => import("./modules/pages/UserPage")); -import NewPage from "./modules/pages/NewPage"; -const NewPost = React.lazy(() => import("./pages/NewPost")); - import TagPage from "./pages/TagPage"; import SearchResults from "./pages/SearchResults"; +const LogsPage = React.lazy(() => import("./components/logging/LogsPage")); + const Wizard = React.lazy(() => import("./pages/Wizard")); - -import Organizations from "./pages/Organizations"; -import LogsPage from "./components/logging/LogsPage"; - const ProviderSettings = React.lazy(() => import("./pages/ProviderSettings")); const PlaygroundEditor = React.lazy(() => import("./pages/PlaygroundEditor")); const PlaygroundEditorLLM = React.lazy(() => import("./pages/PlaygroundEditorLLM")); @@ -55,105 +48,165 @@ const AdminPage = React.lazy(() => import("./pages/AdminPage")); const PlaygroundImages = React.lazy(() => import("./pages/PlaygroundImages")); const PlaygroundImageEditor = React.lazy(() => import("./pages/PlaygroundImageEditor")); const VideoGenPlayground = React.lazy(() => import("./pages/VideoGenPlayground")); +const GridSearchPlayground = React.lazy(() => import("./modules/places/GridSearchPlayground")); +const GridSearch = React.lazy(() => import("./modules/places/gridsearch/GridSearch")); +const PlacesModule = React.lazy(() => import("./modules/places/index")); +const LocationDetail = React.lazy(() => import("./modules/places/LocationDetail")); const PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCanvas")); const TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground")); const VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor }))); const Tetris = React.lazy(() => import("./apps/tetris/Tetris")); const FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser")); const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground")); - +const PlaygroundChat = React.lazy(() => import("./pages/PlaygroundChat")); +const SupportChat = React.lazy(() => import("./pages/SupportChat")); const VersionMap = React.lazy(() => import("./pages/VersionMap")); +const UserCollections = React.lazy(() => import("./pages/UserCollections")); + +const Collections = React.lazy(() => import("./pages/Collections")); +const NewCollection = React.lazy(() => import("./pages/NewCollection")); +const UserPage = React.lazy(() => import("./modules/pages/UserPage")); +const NewPage = React.lazy(() => import("./modules/pages/NewPage")); +const NewPost = React.lazy(() => import("./modules/posts/NewPost")); +const EditPost = React.lazy(() => import("./modules/posts/EditPost")); // <GlobalDebug /> import { EcommerceBundleWrapper } from "./bundles/ecommerce"; +// Create a single tracker instance outside the component to avoid re-creation on re-renders +/* +const tracker = new Tracker({ + projectKey: import.meta.env.VITE_OPENREPLAY_KEY || 'wiN7QPfX48YLyozADQmW', + respectDoNotTrack: false, + __DISABLE_SECURE_MODE: true, +}); +tracker.use(trackerAssist()); +*/ + const AppWrapper = () => { const location = useLocation(); + // Start tracker once on mount + /* + React.useEffect(() => { + + tracker.start().catch(() => { + // Silently ignore โ€” DoNotTrack is active or browser doesn't support required APIs + console.log('OpenReplay tracker failed to start'); + }); + + }, []); - const isFullScreenPage = location.pathname.startsWith('/video-feed'); + // Update user identity when auth state changes + React.useEffect(() => { + if (user?.email) { + tracker.setUserID(user.email); + tracker.setMetadata('roles', roles.join(',')); + } + }, [user?.email, roles]); +*/ + const searchParams = new URLSearchParams(location.search); + const isPageEditor = location.pathname.includes('/pages/') && searchParams.get('edit') === 'true'; + const isFullScreenPage = location.pathname.startsWith('/video-feed') || isPageEditor; const containerClassName = isFullScreenPage ? "flex flex-col min-h-svh transition-colors duration-200 h-full" - : "mx-auto 2xl:max-w-7xl flex flex-col min-h-svh transition-colors duration-200 h-full"; + : "mx-auto max-w-[1400px] flex flex-col min-h-svh transition-colors duration-200 h-full"; + + const { showGlobalFooter } = useAppStore(); const ecommerce = import.meta.env.VITE_ENABLE_ECOMMERCE === 'true'; return ( <div className={containerClassName}> {!isFullScreenPage && <TopNavigation />} - <GlobalDragDrop /> - <Routes> - {/* Top-level routes (no organization context) */} - <Route path="/" element={<Index />} /> - <Route path="/auth" element={<Auth />} /> - <Route path="/auth/update-password" element={<React.Suspense fallback={<div>Loading...</div>}><UpdatePassword /></React.Suspense>} /> - <Route path="/profile" element={<Profile />} /> - <Route path="/post/:id" element={<React.Suspense fallback={<div>Loading...</div>}><Post /></React.Suspense>} /> - <Route path="/video/:id" element={<React.Suspense fallback={<div>Loading...</div>}><Post /></React.Suspense>} /> - <Route path="/user/:userId" element={<UserProfile />} /> - <Route path="/user/:userId/collections" element={<UserCollections />} /> - <Route path="/user/:userId/pages/new" element={<NewPage />} /> - <Route path="/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} /> - <Route path="/collections/new" element={<NewCollection />} /> - <Route path="/collections/:userId/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><Collections /></React.Suspense>} /> - <Route path="/tags/:tag" element={<TagPage />} /> - <Route path="/latest" element={<Index />} /> - <Route path="/top" element={<Index />} /> - <Route path="/categories" element={<Index />} /> - <Route path="/categories/:slug" element={<Index />} /> - <Route path="/search" element={<SearchResults />} /> - <Route path="/wizard" element={<React.Suspense fallback={<div>Loading...</div>}><Wizard /></React.Suspense>} /> - <Route path="/new" element={<React.Suspense fallback={<div>Loading...</div>}><NewPost /></React.Suspense>} /> - <Route path="/version-map/:id" element={ - <React.Suspense fallback={<div className="flex items-center justify-center h-screen">Loading map...</div>}> - <VersionMap /> - </React.Suspense> - } /> - <Route path="/organizations" element={<Organizations />} /> - <Route path="/settings/providers" element={<React.Suspense fallback={<div>Loading...</div>}><ProviderSettings /></React.Suspense>} /> - <Route path="/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} /> - <Route path="/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} /> - <Route path="/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} /> - <Route path="/playground-video-player-intern" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlaygroundIntern /></React.Suspense>} /> - <Route path="/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} /> - <Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} /> + <React.Suspense fallback={null}><GlobalDragDrop /></React.Suspense> + <main className="flex-1"> + <Routes> + {/* Top-level routes (no organization context) */} + <Route path="/" element={<Index />} /> + <Route path="/auth" element={<Auth />} /> + <Route path="/auth/update-password" element={<React.Suspense fallback={<div>Loading...</div>}><UpdatePassword /></React.Suspense>} /> + <Route path="/profile/*" element={<Profile />} /> + <Route path="/post/new" element={<React.Suspense fallback={<div>Loading...</div>}><EditPost /></React.Suspense>} /> + <Route path="/post/:id" element={<React.Suspense fallback={<div>Loading...</div>}><Post /></React.Suspense>} /> + <Route path="/post/:id/edit" element={<React.Suspense fallback={<div>Loading...</div>}><EditPost /></React.Suspense>} /> + <Route path="/video/:id" element={<React.Suspense fallback={<div>Loading...</div>}><Post /></React.Suspense>} /> + <Route path="/user/:userId" element={<UserProfile />} /> + <Route path="/user/:userId/posts" element={<UserProfile />} /> + <Route path="/user/:userId/pages" element={<UserProfile />} /> + <Route path="/user/:userId/pictures" element={<UserProfile />} /> + <Route path="/user/:userId/collections" element={<React.Suspense fallback={<div>Loading...</div>}><UserCollections /></React.Suspense>} /> + <Route path="/user/:userId/pages/new" element={<React.Suspense fallback={<div>Loading...</div>}><NewPage /></React.Suspense>} /> + <Route path="/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} /> + <Route path="/collections/new" element={<React.Suspense fallback={<div>Loading...</div>}><NewCollection /></React.Suspense>} /> + <Route path="/collections/:userId/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><Collections /></React.Suspense>} /> + <Route path="/tags/:tag" element={<TagPage />} /> + <Route path="/latest" element={<Index />} /> + <Route path="/top" element={<Index />} /> + <Route path="/posts" element={<Index />} /> + <Route path="/pages" element={<Index />} /> + <Route path="/categories" element={<Index />} /> + <Route path="/categories/*" element={<Index />} /> + <Route path="/search" element={<SearchResults />} /> + <Route path="/wizard" element={<React.Suspense fallback={<div>Loading...</div>}><Wizard /></React.Suspense>} /> + <Route path="/new" element={<React.Suspense fallback={<div>Loading...</div>}><NewPost /></React.Suspense>} /> + <Route path="/version-map/:id" element={ + <React.Suspense fallback={<div className="flex items-center justify-center h-screen">Loading map...</div>}> + <VersionMap /> + </React.Suspense> + } /> + <Route path="/settings/providers" element={<React.Suspense fallback={<div>Loading...</div>}><ProviderSettings /></React.Suspense>} /> + <Route path="/playground/editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditor /></React.Suspense>} /> + <Route path="/playground/editor-llm" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundEditorLLM /></React.Suspense>} /> + <Route path="/playground/video-player" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlayground /></React.Suspense>} /> + <Route path="/playground-video-player-intern" element={<React.Suspense fallback={<div>Loading...</div>}><VideoPlayerPlaygroundIntern /></React.Suspense>} /> + <Route path="/video-feed" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} /> + <Route path="/video-feed/:id" element={<React.Suspense fallback={<div>Loading...</div>}><VideoFeedPlayground /></React.Suspense>} /> - {/* Admin Routes */} - <Route path="/admin/*" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} /> + {/* Admin Routes */} + <Route path="/admin/*" element={<React.Suspense fallback={<div>Loading...</div>}><AdminPage /></React.Suspense>} /> - {/* Playground Routes */} - <Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} /> - <Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} /> - <Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} /> - <Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} /> - <Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} /> - <Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} /> - <Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} /> - <Route path="/test-cache/:id" element={<CacheTest />} /> + {/* Playground Routes */} + <Route path="/playground/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearchPlayground /></React.Suspense>} /> + <Route path="/products/gridsearch" element={<React.Suspense fallback={<div>Loading...</div>}><GridSearch /></React.Suspense>} /> + <Route path="/products/places/detail/:place_id" element={<React.Suspense fallback={<div>Loading...</div>}><LocationDetail /></React.Suspense>} /> + <Route path="/products/places/*" element={<React.Suspense fallback={<div>Loading...</div>}><PlacesModule /></React.Suspense>} /> + <Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} /> + <Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} /> + <Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} /> + <Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} /> + <Route path="/types-editor" element={<React.Suspense fallback={<div>Loading...</div>}><TypesPlayground /></React.Suspense>} /> + <Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} /> + <Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} /> + <Route path="/playground/chat" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundChat /></React.Suspense>} /> + <Route path="/support-chat" element={<React.Suspense fallback={<div>Loading...</div>}><SupportChat /></React.Suspense>} /> - {/* Logs */} - <Route path="/logs" element={<LogsPage />} /> + {/* Logs */} + <Route path="/logs" element={<React.Suspense fallback={<div>Loading...</div>}><LogsPage /></React.Suspense>} /> - {/* Apps */} - <Route path="/app/tetris" element={<React.Suspense fallback={<div>Loading...</div>}><Tetris /></React.Suspense>} /> - <Route path="/app/filebrowser" element={<React.Suspense fallback={<div>Loading...</div>}><FileBrowser /></React.Suspense>} /> + {/* Apps */} + <Route path="/app/tetris" element={<React.Suspense fallback={<div>Loading...</div>}><Tetris /></React.Suspense>} /> + <Route path="/app/filebrowser/*" element={<React.Suspense fallback={<div>Loading...</div>}><FileBrowser /></React.Suspense>} /> - {/* Ecommerce Routes */} - {(ecommerce) && ( - <> - <Route path="/cart/*" element={<EcommerceBundleWrapper />} /> - <Route path="/checkout/*" element={<EcommerceBundleWrapper />} /> - <Route path="/shipping/*" element={<EcommerceBundleWrapper />} /> - <Route path="/returns/*" element={<EcommerceBundleWrapper />} /> - <Route path="/privacy/*" element={<EcommerceBundleWrapper />} /> - <Route path="/terms/*" element={<EcommerceBundleWrapper />} /> - <Route path="/purchases/*" element={<EcommerceBundleWrapper />} /> - </> - )} + {/* Ecommerce Routes */} + {(ecommerce) && ( + <> + <Route path="/cart/*" element={<EcommerceBundleWrapper />} /> + <Route path="/checkout/*" element={<EcommerceBundleWrapper />} /> + <Route path="/shipping/*" element={<EcommerceBundleWrapper />} /> + <Route path="/returns/*" element={<EcommerceBundleWrapper />} /> + <Route path="/privacy/*" element={<EcommerceBundleWrapper />} /> + <Route path="/terms/*" element={<EcommerceBundleWrapper />} /> + <Route path="/impressum/*" element={<EcommerceBundleWrapper />} /> + <Route path="/purchases/*" element={<EcommerceBundleWrapper />} /> + </> + )} - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - <Route path="*" element={<React.Suspense fallback={<div>Loading...</div>}><NotFound /></React.Suspense>} /> - </Routes > + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + <Route path="*" element={<React.Suspense fallback={<div>Loading...</div>}><NotFound /></React.Suspense>} /> + </Routes > + </main> + {!isFullScreenPage && showGlobalFooter && <Footer />} </div > ); }; @@ -161,7 +214,7 @@ const AppWrapper = () => { import { initFormatDetection } from '@/utils/formatDetection'; import { SWRConfig } from 'swr'; -import CacheTest from "./pages/CacheTest"; + // ... (imports) @@ -173,6 +226,8 @@ import { StreamInvalidator } from "@/components/StreamInvalidator"; import { ActionProvider } from "@/actions/ActionProvider"; import { HelmetProvider } from 'react-helmet-async'; +import Tracker from '@openreplay/tracker'; +import trackerAssist from '@openreplay/tracker-assist'; // ... previous imports ... @@ -187,35 +242,32 @@ const App = () => { <QueryClientProvider client={queryClient}> <AuthProvider> <LogProvider> - <PostNavigationProvider> - <MediaRefreshProvider> + <MediaRefreshProvider> + <TooltipProvider> + <Toaster /> + <Sonner /> + <ActionProvider> + <BrowserRouter> + <DragDropProvider> + <OrganizationProvider> + <ProfilesProvider> + <WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}> + <StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}> + <StreamInvalidator /> + <FeedCacheProvider> + <AppWrapper /> + </FeedCacheProvider> + </StreamProvider> + </WebSocketProvider> + </ProfilesProvider> + </OrganizationProvider> + </DragDropProvider> + </BrowserRouter> + </ActionProvider> - <TooltipProvider> - <Toaster /> - <Sonner /> - <ActionProvider> - <BrowserRouter> - <DragDropProvider> - <OrganizationProvider> - <ProfilesProvider> - <WebSocketProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}> - <StreamProvider url={import.meta.env.VITE_SERVER_IMAGE_API_URL}> - <StreamInvalidator /> - <FeedCacheProvider> - <AppWrapper /> - </FeedCacheProvider> - </StreamProvider> - </WebSocketProvider> - </ProfilesProvider> - </OrganizationProvider> - </DragDropProvider> - </BrowserRouter> - </ActionProvider> + </TooltipProvider> - </TooltipProvider> - - </MediaRefreshProvider> - </PostNavigationProvider> + </MediaRefreshProvider> </LogProvider> </AuthProvider> </QueryClientProvider> diff --git a/packages/ui/src/EmbedApp.tsx b/packages/ui/src/EmbedApp.tsx index 7800615a..ce4650cd 100644 --- a/packages/ui/src/EmbedApp.tsx +++ b/packages/ui/src/EmbedApp.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { EmbedRenderer } from './pages/Post/renderers/EmbedRenderer'; +import { EmbedRenderer } from './modules/posts/views/renderers/EmbedRenderer'; import UserPage from '@/modules/pages/UserPage'; import { Toaster } from "@/components/ui/sonner"; import { QueryClientProvider } from "@tanstack/react-query"; diff --git a/packages/ui/src/apps/filebrowser/FileBrowser.tsx b/packages/ui/src/apps/filebrowser/FileBrowser.tsx index 096fb661..0272ff8a 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowser.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowser.tsx @@ -1,22 +1,121 @@ -import React from 'react'; +import type { INode } from '@/modules/storage/types'; +import React, { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; +import { T } from '@/i18n'; import { FileBrowserProvider, useFileBrowser } from './FileBrowserContext'; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; +import { useIsMobile } from '@/hooks/use-mobile'; import LayoutToolbar from './LayoutToolbar'; import PanelSide from './PanelSide'; /** * Standalone FileBrowser page โ€” Krusader-style dual pane. * Inner component that requires FileBrowserProvider context. + * Syncs active panel mount/path to the browser URL. */ -const FileBrowserInner: React.FC = () => { +const FileBrowserInner: React.FC<{ disableRoutingSync?: boolean, onSelect?: (node: INode | null, mount?: string) => void }> = ({ disableRoutingSync, onSelect }) => { const { loading } = useAuth(); - const { layout } = useFileBrowser(); + const { layout, activePanel, allowPanels } = useFileBrowser(); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + const hasInitialSelectedRef = React.useRef(false); + + // Propagate selection to parent if requested + useEffect(() => { + if (onSelect) { + onSelect(activePanel.selected.length === 1 ? activePanel.selected[0] : null, activePanel.mount); + } + }, [activePanel.selected, activePanel.mount, onSelect]); + + // Sync active panel mount/path/glob/folders/file โ†’ browser URL + useEffect(() => { + if (disableRoutingSync) return; + + const cleanPath = activePanel.path.replace(/^\/+/, ''); + + // Persist to local storage so we can restore location on a fresh visit + localStorage.setItem('fb-last-mount', activePanel.mount); + localStorage.setItem('fb-last-path', cleanPath); + + const urlPath = cleanPath + ? `/app/filebrowser/${activePanel.mount}/${cleanPath}` + : `/app/filebrowser/${activePanel.mount}`; + + // Preserve and modify query params + const searchParams = new URLSearchParams(window.location.search); + + if (activePanel.glob && activePanel.glob !== '*.*') { + searchParams.set('glob', activePanel.glob); + } else { + searchParams.delete('glob'); + } + + if (activePanel.showFolders === false) { + searchParams.set('folders', '0'); + } else { + searchParams.delete('folders'); + } + + if (activePanel.showExplorer === false) { + searchParams.set('showExplorer', '0'); + } else { + searchParams.delete('showExplorer'); + } + + if (activePanel.showPreview === false) { + searchParams.set('showPreview', '0'); + } else { + searchParams.delete('showPreview'); + } + + const initialFile = searchParams.get('file'); + + if (activePanel.selected.length === 1) { + const first = activePanel.selected[0]; + if (first.type !== 'dir' && first.mime !== 'inode/directory') { + searchParams.set('file', first.name); + hasInitialSelectedRef.current = true; + } else { + searchParams.delete('file'); + hasInitialSelectedRef.current = true; + } + } else if (activePanel.selected.length > 1) { + searchParams.delete('file'); + hasInitialSelectedRef.current = true; + } else { + // activePanel.selected is empty + // If the user already made a selection or there wasn't an initial file to preserve, clear it. + if (!initialFile || hasInitialSelectedRef.current) { + searchParams.delete('file'); + } + } + + if (activePanel.searchQuery) { + searchParams.set('search', activePanel.searchQuery); + if (activePanel.searchFullText) { + searchParams.set('fullText', '1'); + } else { + searchParams.delete('fullText'); + } + } else { + searchParams.delete('search'); + searchParams.delete('fullText'); + } + + const newSearch = searchParams.toString() ? `?${searchParams.toString()}` : ''; + const currentPathWithSearch = window.location.pathname + window.location.search; + const newPathWithSearch = urlPath + newSearch; + + if (currentPathWithSearch !== newPathWithSearch) { + navigate(newPathWithSearch, { replace: true }); + } + }, [activePanel.mount, activePanel.path, activePanel.glob, activePanel.showFolders, activePanel.selected, activePanel.searchQuery, activePanel.searchFullText, navigate]); if (loading) { return ( <div className="flex items-center justify-center flex-1 text-muted-foreground"> - Loadingโ€ฆ + <T>Loadingโ€ฆ</T> </div> ); } @@ -24,17 +123,17 @@ const FileBrowserInner: React.FC = () => { return ( <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}> {/* โ•โ•โ• Layout Toolbar โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} - <LayoutToolbar /> + {allowPanels && <LayoutToolbar />} {/* โ•โ•โ• Resizable Panes โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} - <ResizablePanelGroup direction="horizontal" style={{ flex: 1, overflow: 'hidden' }}> - <ResizablePanel defaultSize={layout === 'dual' ? 50 : 100} minSize={20} order={1} id="fb-left"> + <ResizablePanelGroup direction={isMobile ? "vertical" : "horizontal"} style={{ flex: 1, overflow: 'hidden' }}> + <ResizablePanel defaultSize={allowPanels && layout === 'dual' ? 50 : 100} order={1} id="fb-left"> <PanelSide side="left" /> </ResizablePanel> - {layout === 'dual' && ( + {allowPanels && layout === 'dual' && ( <> <ResizableHandle withHandle /> - <ResizablePanel defaultSize={50} minSize={20} order={2} id="fb-right"> + <ResizablePanel defaultSize={50} order={2} id="fb-right"> <PanelSide side="right" /> </ResizablePanel> </> @@ -47,12 +146,98 @@ const FileBrowserInner: React.FC = () => { /** * Exported wrapper โ€” provides FileBrowserProvider so this component * works both from the standalone app AND when lazy-loaded by App.tsx. + * Reads initial mount/path from URL path and display options from query params. */ -const FileBrowser: React.FC = () => ( - <FileBrowserProvider> - <FileBrowserInner /> - </FileBrowserProvider> -); +const FileBrowser: React.FC<{ + allowPanels?: boolean, + mode?: 'simple' | 'advanced', + index?: boolean, + disableRoutingSync?: boolean, + onSelect?: (node: INode | null, mount?: string) => void +}> = ({ allowPanels, mode, index, disableRoutingSync, onSelect }) => { + const location = useLocation(); + + let initialMount: string | undefined; + let initialPath: string | undefined; + + if (!disableRoutingSync) { + // Parse mount and path from URL: /app/filebrowser/{mount}/{...path} + const urlRest = location.pathname.replace(/^\/app\/filebrowser\/?/, ''); + const segments = urlRest.split('/').filter(Boolean); + + if (segments.length > 0) { + initialMount = segments[0]; + initialPath = segments.slice(1).join('/'); + } else { + initialMount = localStorage.getItem('fb-last-mount') || undefined; + initialPath = localStorage.getItem('fb-last-path') || undefined; + } + } + + // Read display options from URL query params + const searchParams = new URLSearchParams(location.search); + const viewParam = searchParams.get('view'); + const toolbarParam = searchParams.get('toolbar'); + const explorerParam = searchParams.get('showExplorer'); + const previewParam = searchParams.get('showPreview'); + const panelsParam = searchParams.get('panels'); + const modeParam = searchParams.get('mode'); + const fileParam = searchParams.get('file'); + const searchParam = searchParams.get('search'); + const fullTextParam = searchParams.get('fullText'); + const initialViewMode = viewParam === 'thumbs' ? 'thumbs' as const : viewParam === 'list' ? 'list' as const : viewParam === 'tree' ? 'tree' as const : undefined; + const initialShowToolbar = toolbarParam === '0' ? false : toolbarParam === '1' ? true : undefined; + const initialShowExplorer = explorerParam === '0' ? false : true; + const initialShowPreview = previewParam === '0' ? false : true; + const initialAllowPanels = panelsParam === '1' ? true : (panelsParam === '0' ? false : allowPanels); + const initialMode = modeParam === 'advanced' ? 'advanced' : (modeParam === 'simple' ? 'simple' : mode); + const initialSearchQuery = searchParam || undefined; + const initialSearchFullText = fullTextParam === '1'; + + // Read index param + const indexParam = searchParams.get('index'); + const initialIndex = indexParam === '0' ? false : (indexParam === '1' ? true : index); + + // Read filter params + const initialGlob = searchParams.get('glob') || '*.*'; + const initialShowFolders = searchParams.get('folders') === '0' ? false : true; + + // ?file= overrides path to parent dir and pre-selects the file + let finalPath = initialPath ? `/${initialPath}` : undefined; + let initialFile: string | undefined; + if (fileParam) { + const clean = fileParam.replace(/^\/+/, ''); + const lastSlash = clean.lastIndexOf('/'); + if (lastSlash >= 0) { + // If the file param is a full path, use its directory as the final path + finalPath = '/' + clean.slice(0, lastSlash); + initialFile = clean.slice(lastSlash + 1); + } else { + // Otherwise, it's just a filename in the current path + initialFile = clean; + } + } + + return ( + <FileBrowserProvider + initialMount={initialMount} + initialPath={finalPath} + initialViewMode={initialViewMode} + initialShowToolbar={initialShowToolbar} + initialShowExplorer={initialShowExplorer} + initialShowPreview={initialShowPreview} + initialAllowPanels={initialAllowPanels} + initialMode={initialMode} + initialFile={initialFile} + initialIndex={initialIndex} + initialGlob={initialGlob} + initialShowFolders={initialShowFolders} + initialSearchQuery={initialSearchQuery} + initialSearchFullText={initialSearchFullText} + > + <FileBrowserInner disableRoutingSync={disableRoutingSync} onSelect={onSelect} /> + </FileBrowserProvider> + ); +}; export default FileBrowser; - diff --git a/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx b/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx index e9cb5305..8e81b404 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowserApp.tsx @@ -7,14 +7,20 @@ import { Toaster } from '@/components/ui/sonner'; import { FileBrowserProvider } from './FileBrowserContext'; import FileBrowser from './FileBrowser'; -const FileBrowserApp: React.FC = () => { +interface FileBrowserAppProps { + allowPanels?: boolean; + mode?: 'simple' | 'advanced'; + index?: boolean; +} + +const FileBrowserApp: React.FC<FileBrowserAppProps> = ({ allowPanels = false, mode = 'simple', index = true }) => { return ( <QueryClientProvider client={queryClient}> <AuthProvider> <MemoryRouter> <FileBrowserProvider> <div className="flex flex-col h-full w-full bg-background text-foreground"> - <FileBrowser /> + <FileBrowser allowPanels={allowPanels} mode={mode} index={index} /> </div> </FileBrowserProvider> <Toaster /> diff --git a/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx b/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx index 692e826a..b9116272 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowserContext.tsx @@ -8,7 +8,12 @@ export interface PanelState { mount: string; path: string; glob: string; - selected: INode | null; + showFolders: boolean; + showExplorer?: boolean; + showPreview?: boolean; + searchQuery?: string; + searchFullText?: boolean; + selected: INode[]; } export type Side = 'left' | 'right'; @@ -44,6 +49,23 @@ interface FileBrowserContextType { /** Remove a panel (tab) from a side โ€” won't remove last panel */ removePanel: (side: Side, idx: number) => void; + + /** Display options (can be set from URL params) */ + viewMode: 'list' | 'thumbs' | 'tree'; + setViewMode: (mode: 'list' | 'thumbs' | 'tree') => void; + showToolbar: boolean; + setShowToolbar: (v: boolean) => void; + allowPanels: boolean; + setAllowPanels: (v: boolean) => void; + mode: 'simple' | 'advanced'; + setMode: (v: 'simple' | 'advanced') => void; + index: boolean; + setIndex: (v: boolean) => void; + + /** File to auto-select on initial load */ + initialFile?: string; + + initialSearchFullText?: boolean; } const FileBrowserContext = createContext<FileBrowserContextType | undefined>(undefined); @@ -61,17 +83,51 @@ function createPanel(overrides?: Partial<PanelState>): PanelState { mount: 'machines', path: '/', glob: '*.*', - selected: null, + showFolders: true, + showExplorer: true, + showPreview: true, + searchFullText: false, + selected: [], ...overrides, }; } // โ”€โ”€ Provider โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export const FileBrowserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const FileBrowserProvider: React.FC<{ + children: React.ReactNode; + initialMount?: string; + initialPath?: string; + initialViewMode?: 'list' | 'thumbs' | 'tree'; + initialShowToolbar?: boolean; + initialAllowPanels?: boolean; + initialMode?: 'simple' | 'advanced'; + initialFile?: string; + initialIndex?: boolean; + initialGlob?: string; + initialShowFolders?: boolean; + initialShowExplorer?: boolean; + initialShowPreview?: boolean; + initialSearchQuery?: string; + initialSearchFullText?: boolean; +}> = ({ children, initialMount, initialPath, initialViewMode, initialShowToolbar, initialAllowPanels, initialMode, initialFile, initialIndex, initialGlob, initialShowFolders, initialShowExplorer, initialShowPreview, initialSearchQuery, initialSearchFullText }) => { const [layout, setLayout] = useState<LayoutMode>('single'); const [linked, setLinked] = useState(false); - const [leftPanels, setLeftPanels] = useState<PanelState[]>([createPanel()]); + const [viewMode, setViewMode] = useState<'list' | 'thumbs' | 'tree'>(initialViewMode || 'list'); + const [showToolbar, setShowToolbar] = useState(initialShowToolbar !== undefined ? initialShowToolbar : true); + const [allowPanels, setAllowPanels] = useState(initialAllowPanels !== undefined ? initialAllowPanels : false); + const [mode, setMode] = useState<'simple' | 'advanced'>(initialMode || 'simple'); + const [index, setIndex] = useState(initialIndex !== undefined ? initialIndex : true); + const [leftPanels, setLeftPanels] = useState<PanelState[]>([createPanel({ + ...(initialMount ? { mount: initialMount } : {}), + ...(initialPath ? { path: initialPath } : {}), + ...(initialGlob ? { glob: initialGlob } : {}), + ...(initialShowFolders !== undefined ? { showFolders: initialShowFolders } : {}), + ...(initialShowExplorer !== undefined ? { showExplorer: initialShowExplorer } : {}), + ...(initialShowPreview !== undefined ? { showPreview: initialShowPreview } : {}), + ...(initialSearchQuery ? { searchQuery: initialSearchQuery } : {}), + ...(initialSearchFullText !== undefined ? { searchFullText: initialSearchFullText } : {}), + })]); const [rightPanels, setRightPanels] = useState<PanelState[]>([createPanel()]); const [activeSide, setActiveSide] = useState<Side>('left'); const [activePanelIdx, setActivePanelIdx] = useState(0); @@ -123,6 +179,13 @@ export const FileBrowserProvider: React.FC<{ children: React.ReactNode }> = ({ c activeSide, activePanelIdx, setActivePanel, activePanel, updatePanel, addPanel, removePanel, + viewMode, setViewMode, + showToolbar, setShowToolbar, + allowPanels, setAllowPanels, + mode, setMode, + index, setIndex, + initialFile, + initialSearchFullText, }}> {children} </FileBrowserContext.Provider> diff --git a/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx b/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx index 818369a1..db31d29c 100644 --- a/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx +++ b/packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx @@ -1,525 +1,869 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { Loader2 } from 'lucide-react'; -import { useAuth } from '@/hooks/useAuth'; -import ImageLightbox from '@/components/ImageLightbox'; -import LightboxText from '@/components/LightboxText'; - -import type { INode, SortKey } from '@/modules/storage/types'; -import { getMimeCategory, sortNodes, vfsUrl, formatSize } from '@/modules/storage/helpers'; -import FileBrowserToolbar from '@/modules/storage/FileBrowserToolbar'; -import FileListView from '@/modules/storage/FileListView'; -import FileGridView from '@/modules/storage/FileGridView'; -import FileDetailPanel from '@/modules/storage/FileDetailPanel'; - -// โ”€โ”€ Props โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -export interface FileBrowserPanelProps { - mount?: string; - path?: string; - glob?: string; - mode?: 'simple' | 'advanced'; - viewMode?: 'list' | 'thumbs'; - sortBy?: SortKey; - showToolbar?: boolean; - canChangeMount?: boolean; - allowFileViewer?: boolean; - allowLightbox?: boolean; - allowDownload?: boolean; - jail?: boolean; - onPathChange?: (path: string) => void; - onMountChange?: (mount: string) => void; - onSelect?: (node: INode | null) => void; -} - -// โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const FileBrowserPanel: React.FC<FileBrowserPanelProps> = (props) => { - - const { - mount: mountProp = 'machines', - path: pathProp = '/', - glob = '*.*', - mode = 'advanced', - viewMode: initialViewMode = 'list', - sortBy: initialSort = 'name', - showToolbar = true, - canChangeMount = true, - allowFileViewer = true, - allowLightbox = true, - allowDownload = true, - jail = false, - onPathChange, - onMountChange, - onSelect, - } = props; - - const { session } = useAuth(); - const accessToken = session?.access_token; - - // โ”€โ”€ Controlled / uncontrolled mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const isControlled = !!onPathChange; - const [internalPath, setInternalPath] = useState(pathProp); - const [internalMount, setInternalMount] = useState(mountProp); - const mount = onMountChange ? mountProp : internalMount; - const currentPath = isControlled ? pathProp : internalPath; - - // Jail: normalize the root path for comparison - const jailRoot = pathProp.replace(/\/+$/, '') || '/'; - - const updatePath = useCallback((newPath: string) => { - if (jail) { - const norm = newPath.replace(/\/+$/, '') || '/'; - const root = pathProp.replace(/\/+$/, '') || '/'; - if (root !== '/' && !norm.startsWith(root) && norm !== root) return; - } - if (isControlled) onPathChange!(newPath); - else setInternalPath(newPath); - }, [isControlled, onPathChange, jail, pathProp]); - - const updateMount = useCallback((newMount: string) => { - if (onMountChange) onMountChange(newMount); - else setInternalMount(newMount); - updatePath('/'); - }, [onMountChange, updatePath]); - - // โ”€โ”€ Available mounts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const [availableMounts, setAvailableMounts] = useState<string[]>([]); - useEffect(() => { - const headers: Record<string, string> = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - fetch('/api/vfs/mounts', { headers }) - .then(r => r.ok ? r.json() : []) - .then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name))) - .catch(() => { }); - }, [accessToken]); - - // โ”€โ”€ Core state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const [nodes, setNodes] = useState<INode[]>([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode); - const [sortBy, setSortBy] = useState<SortKey>(initialSort); - const [sortAsc, setSortAsc] = useState(true); - const [focusIdx, setFocusIdx] = useState(-1); - const [selected, setSelected] = useState<INode | null>(null); - const listRef = useRef<HTMLDivElement>(null); - const containerRef = useRef<HTMLDivElement>(null); - const returnTargetRef = useRef<string | null>(null); - - useEffect(() => { - if (onSelect) onSelect(selected); - }, [selected, onSelect]); - - // โ”€โ”€ Zoom (persisted) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const [thumbSize, setThumbSize] = useState(() => { - const v = localStorage.getItem('fb-thumb-size'); - return v ? Math.max(60, Math.min(200, Number(v))) : 80; - }); - const [fontSize, setFontSize] = useState(() => { - const v = localStorage.getItem('fb-font-size'); - return v ? Math.max(10, Math.min(18, Number(v))) : 14; - }); - const zoomIn = () => { - if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); - else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; }); - }; - const zoomOut = () => { - if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); - else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; }); - }; - - // โ”€โ”€ Fetch โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const fetchDir = useCallback(async (dirPath: string) => { - setLoading(true); - setError(null); - setSelected(null); - setFocusIdx(0); - try { - const clean = dirPath.replace(/^\/+/, ''); - const base = vfsUrl('ls', mount, clean); - const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base; - const headers: Record<string, string> = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - const res = await fetch(url, { headers }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - setNodes(await res.json()); - } catch (e: any) { - setError(e.message || 'Failed to load directory'); - setNodes([]); - } finally { - setLoading(false); - } - }, [mount, glob, accessToken]); - - useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]); - - useEffect(() => { - if (!isControlled) setInternalPath(pathProp); - }, [pathProp, isControlled]); - - const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; - - // โ”€โ”€ Sorted items โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const canGoUp = (() => { - if (currentPath === '/' || currentPath === '') return false; - if (jail) { - const normalized = currentPath.replace(/\/+$/, '') || '/'; - return normalized !== jailRoot && normalized !== jailRoot.replace(/\/+$/, ''); - } - return true; - })(); - const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]); - const itemCount = sorted.length + (canGoUp ? 1 : 0); - const getNode = (idx: number): INode | null => { - if (canGoUp && idx === 0) return null; - return sorted[canGoUp ? idx - 1 : idx] ?? null; - }; - - // โ”€โ”€ Navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const goUp = useCallback(() => { - if (!canGoUp) return; - const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean); - const leaving = parts.pop(); - if (leaving) returnTargetRef.current = leaving; - updatePath(parts.length ? parts.join('/') : '/'); - }, [currentPath, canGoUp, updatePath]); - - const getFileUrl = (node: INode) => { - const base = vfsUrl('get', mount, node.path); - return tokenParam ? `${base}?${tokenParam}` : base; - }; - - const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); }; - const handleDownload = () => { - if (!selected) return; - const a = document.createElement('a'); - a.href = getFileUrl(selected); - a.download = selected.name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - }; - - // โ”€โ”€ Sort toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const cycleSort = () => { - const keys: SortKey[] = ['name', 'ext', 'date', 'type']; - const i = keys.indexOf(sortBy); - if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); } - }; - - // โ”€โ”€ Breadcrumbs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const breadcrumbs = useMemo(() => { - const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean); - const crumbs = [{ label: '/', path: '/' }]; - let acc = ''; - for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); } - if (jail) { - const root = jailRoot === '/' ? '/' : jailRoot; - const rootParts = root === '/' ? 0 : root.split('/').filter(Boolean).length; - return crumbs.slice(rootParts); - } - return crumbs; - }, [currentPath, mount, jail, jailRoot]); - - // Return-to-sender focus - useEffect(() => { - const target = returnTargetRef.current; - if (!target || sorted.length === 0) return; - const idx = sorted.findIndex(n => n.name === target); - if (idx >= 0) { - const realIdx = canGoUp ? idx + 1 : idx; - setFocusIdx(realIdx); - setSelected(sorted[idx]); - requestAnimationFrame(() => { - if (!listRef.current) return; - const items = listRef.current.querySelectorAll('[data-fb-idx]'); - const el = items[realIdx] as HTMLElement | undefined; - el?.scrollIntoView({ block: 'nearest' }); - }); - } - returnTargetRef.current = null; - }, [sorted, canGoUp]); - - // โ”€โ”€ Keyboard navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const scrollItemIntoView = useCallback((idx: number) => { - if (!listRef.current) return; - const items = listRef.current.querySelectorAll('[data-fb-idx]'); - const el = items[idx] as HTMLElement | undefined; - el?.scrollIntoView({ block: 'nearest' }); - }, []); - - const getGridCols = useCallback((): number => { - if (viewMode !== 'thumbs' || !listRef.current) return 1; - const style = getComputedStyle(listRef.current); - const cols = style.gridTemplateColumns.split(' ').length; - return Math.max(1, cols); - }, [viewMode]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (itemCount === 0) return; - - const moveFocus = (next: number) => { - next = Math.max(0, Math.min(itemCount - 1, next)); - setFocusIdx(next); - const node = getNode(next); - if (node) setSelected(node); else setSelected(null); - scrollItemIntoView(next); - }; - - const cols = getGridCols(); - - switch (e.key) { - case 'ArrowRight': - case 'j': { - e.preventDefault(); - moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0); - break; - } - case 'ArrowLeft': - case 'k': { - e.preventDefault(); - moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1); - break; - } - case 'ArrowDown': { - e.preventDefault(); - moveFocus(focusIdx + cols); - break; - } - case 'ArrowUp': { - e.preventDefault(); - moveFocus(focusIdx - cols); - break; - } - case 'Enter': - case ' ': - case 'l': { - e.preventDefault(); - if (focusIdx < 0) break; - const node = getNode(focusIdx); - if (!node) { goUp(); break; } - const cat = getMimeCategory(node); - if (cat === 'dir') updatePath(node.path || node.name); - else if ((cat === 'image' || cat === 'video') && allowLightbox) setLightboxNode(node); - else if (allowFileViewer) openTextLightbox(node); - break; - } - case 'Backspace': - case 'h': { - e.preventDefault(); - goUp(); - break; - } - case 'Home': { - e.preventDefault(); - moveFocus(0); - break; - } - case 'End': { - e.preventDefault(); - moveFocus(itemCount - 1); - break; - } - case 'Escape': { - e.preventDefault(); - setSelected(null); - setFocusIdx(-1); - break; - } - } - }, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols, allowLightbox, allowFileViewer, updatePath]); - - useEffect(() => { containerRef.current?.focus(); }, [currentPath]); - - const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null; - - // โ”€โ”€ Lightbox state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const [lightboxNode, setLightboxNode] = useState<INode | null>(null); - const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]); - const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1; - - const lightboxPrev = () => { if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]); }; - const lightboxNext = () => { if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]); }; - const closeLightbox = () => { setLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); }; - - // โ”€โ”€ Text lightbox state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const [textLightboxNode, setTextLightboxNode] = useState<INode | null>(null); - const openTextLightbox = (node: INode) => setTextLightboxNode(node); - const closeTextLightbox = () => { setTextLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); }; - - // โ”€โ”€ Click handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const onItemClick = (idx: number) => { - setFocusIdx(idx); - const node = getNode(idx); - if (!node) return; - setSelected(prev => prev?.path === node.path ? null : node); - }; - const onItemDoubleClick = (idx: number) => { - const node = getNode(idx); - if (!node) { goUp(); return; } - const cat = getMimeCategory(node); - if (cat === 'dir') { - updatePath(node.path || node.name); - } else if ((cat === 'image' || cat === 'video') && allowLightbox) { - setLightboxNode(node); - } else if (allowFileViewer) { - openTextLightbox(node); - } - }; - - // โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - return ( - <div - ref={containerRef} - tabIndex={0} - onKeyDown={handleKeyDown} - style={{ - display: 'flex', flexDirection: 'column', height: '100%', - border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden', - background: 'var(--background, #0f172a)', color: 'var(--foreground, #e2e8f0)', - fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none', - }} - > - <style>{` - .fb-row:hover { background: var(--accent, #334155) !important; } - .fb-thumb:hover { border-color: var(--ring, #3b82f6) !important; background: var(--accent, #1e293b) !important; } - .fb-tb-btn:hover { background: var(--accent, #334155) !important; color: var(--foreground, #e2e8f0) !important; } - .fb-mount-item:hover { background: var(--accent, #334155) !important; } - @media (max-width: 767px) { .fb-detail-pane { display: none !important; } } - `}</style> - - {/* โ•โ•โ• Toolbar โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} - {showToolbar && ( - <FileBrowserToolbar - canGoUp={canGoUp} - goUp={goUp} - canChangeMount={!jail && canChangeMount} - availableMounts={availableMounts} - mount={mount} - updateMount={updateMount} - mountProp={mountProp} - pathProp={pathProp} - updatePath={updatePath} - breadcrumbs={breadcrumbs} - selectedFile={selectedFile} - handleView={handleView} - handleDownload={handleDownload} - allowDownload={allowDownload} - sortBy={sortBy} - sortAsc={sortAsc} - cycleSort={cycleSort} - zoomIn={zoomIn} - zoomOut={zoomOut} - viewMode={viewMode} - setViewMode={setViewMode} - /> - )} - - {/* โ•โ•โ• Content โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} - {loading ? ( - <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}> - <Loader2 size={16} className="animate-spin" /> - <span style={{ fontSize: 14 }}>Loadingโ€ฆ</span> - </div> - ) : error ? ( - <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}> - {error} - </div> - ) : itemCount === 0 ? ( - <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}> - Empty directory - </div> - ) : ( - <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}> - {viewMode === 'list' ? ( - <FileListView - listRef={listRef} - sorted={sorted} - canGoUp={canGoUp} - goUp={goUp} - focusIdx={focusIdx} - setFocusIdx={setFocusIdx} - selected={selected} - onItemClick={onItemClick} - onItemDoubleClick={onItemDoubleClick} - fontSize={fontSize} - mode={mode} - /> - ) : ( - <FileGridView - listRef={listRef} - sorted={sorted} - canGoUp={canGoUp} - goUp={goUp} - focusIdx={focusIdx} - setFocusIdx={setFocusIdx} - selected={selected} - onItemClick={onItemClick} - onItemDoubleClick={onItemDoubleClick} - thumbSize={thumbSize} - mount={mount} - tokenParam={tokenParam} - /> - )} - - {/* Detail panel (advanced, desktop only) */} - {mode === 'advanced' && selectedFile && ( - <FileDetailPanel file={selectedFile} fileUrl={getFileUrl(selectedFile)} /> - )} - </div> - )} - - {/* โ•โ•โ• Status bar โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} - <div style={{ - padding: '4px 10px', fontSize: 12, borderTop: '1px solid var(--border, #334155)', - color: 'var(--muted-foreground, #64748b)', display: 'flex', justifyContent: 'space-between', - background: 'var(--muted, #1e293b)', - }}> - <span> - {sorted.length} item{sorted.length !== 1 ? 's' : ''} - {' ยท '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))} - {selectedFile ? ` ยท ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''} - </span> - <span>{mount}:{currentPath || '/'}</span> - </div> - - {/* โ•โ•โ• Lightboxes โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} - <ImageLightbox - isOpen={!!lightboxNode} - onClose={closeLightbox} - imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''} - imageTitle={lightboxNode?.name || ''} - currentIndex={lightboxIdx} - totalCount={mediaNodes.length} - onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()} - showPrompt={false} - /> - <LightboxText - isOpen={!!textLightboxNode} - onClose={closeTextLightbox} - url={textLightboxNode ? getFileUrl(textLightboxNode) : ''} - fileName={textLightboxNode?.name || ''} - /> - </div> - ); -}; - -export { FileBrowserPanel }; -export default FileBrowserPanel; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Loader2 } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import ImageLightbox from '@/components/ImageLightbox'; +import LightboxText from '@/modules/storage/views/LightboxText'; +import LightboxIframe from '@/modules/storage/views/LightboxIframe'; +import { renderFileViewer } from '@/modules/storage/FileViewerRegistry'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; + +import type { INode, SortKey } from '@/modules/storage/types'; +import { getMimeCategory, vfsUrl, formatSize } from '@/modules/storage/helpers'; +import FileBrowserToolbar from '@/modules/storage/FileBrowserToolbar'; +import FileListView from '@/modules/storage/FileListView'; +import FileGridView from '@/modules/storage/FileGridView'; +import FileDetailPanel from '@/modules/storage/FileDetailPanel'; +import MarkdownRenderer from '@/components/MarkdownRenderer'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { IMAGE_EXTS, VIDEO_EXTS, CODE_EXTS } from '@/modules/storage/helpers'; +import { T } from '@/i18n'; + +import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter'; +import { useSelection } from '@/modules/storage/hooks/useSelection'; +import { useFilePreview } from '@/modules/storage/hooks/useFilePreview'; +import { useDefaultKeyboardHandler } from '@/modules/storage/hooks/useDefaultKeyboardHandler'; +import { useDefaultSelectionHandler } from '@/modules/storage/hooks/useDefaultSelectionHandler'; +import { useDefaultActions } from '@/modules/storage/hooks/useDefaultActions'; +import { FileTree } from './FileTree'; +import SearchDialog from './SearchDialog'; + +// โ”€โ”€ Props โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface FileBrowserPanelProps { + mount?: string; + path?: string; + glob?: string; + mode?: 'simple' | 'advanced'; + viewMode?: 'list' | 'thumbs' | 'tree'; + sortBy?: SortKey; + showToolbar?: boolean; + canChangeMount?: boolean; + allowFileViewer?: boolean; + allowLightbox?: boolean; + allowPreview?: boolean; + allowDownload?: boolean; + jail?: boolean; + onPathChange?: (path: string) => void; + onMountChange?: (mount: string) => void; + /** If set, auto-open this file in lightbox after directory loads */ + initialFile?: string; + /** If true, automatically loads and renders a readme.md (case-insensitive) in the current directory */ + index?: boolean; + /** If true, allows the fallback FileBrowserPanel to render when no readme is found. */ + allowFallback?: boolean; + /** ID for saving user preferences like viewMode locally (e.g. 'pm-filebrowser-left-panel') */ + autoSaveId?: string; + showFolders?: boolean; + showExplorer?: boolean; + showPreview?: boolean; + showTree?: boolean; + onToggleExplorer?: () => void; + onTogglePreview?: () => void; + onFilterChange?: (glob: string, showFolders: boolean) => void; + onSelect?: (nodes: INode[] | INode | null) => void; + searchQuery?: string; + onSearchQueryChange?: (q: string) => void; + autoFocus?: boolean; + includeSize?: boolean; + splitSizeHorizontal?: number[]; + splitSizeVertical?: number[]; + onLayoutChange?: (sizes: number[], direction: 'horizontal' | 'vertical') => void; + showStatusBar?: boolean; +} + +// โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({ + mount: mountProp = 'machines', + path: pathProp = '/', + glob = '*.*', + mode = 'simple', + viewMode: initialViewMode = 'list', + sortBy: initialSort = 'name', + showToolbar = true, + canChangeMount = false, + allowFileViewer = true, + allowLightbox = true, + allowPreview = true, + allowDownload = true, + jail = false, + initialFile, + allowFallback = true, + autoFocus = true, + includeSize = false, + index = true, + autoSaveId, + showFolders: showFoldersProp, + showExplorer = true, + showPreview = true, + showTree = true, + onToggleExplorer, + onTogglePreview, + onPathChange, + onMountChange, + onSelect, + onFilterChange, + searchQuery, + onSearchQueryChange, + splitSizeHorizontal, + splitSizeVertical, + onLayoutChange, + showStatusBar = true +}) => { + + const { session } = useAuth(); + const accessToken = session?.access_token; + + const [readmeContent, setReadmeContent] = useState<string | null>(null); + const [selectedReadmeContent, setSelectedReadmeContent] = useState<string | null>(null); + + // โ”€โ”€ Controlled / uncontrolled mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const [internalMount, setInternalMount] = useState(mountProp); + const mount = onMountChange ? mountProp : internalMount; + + const [internalGlob, setInternalGlob] = useState(glob); + const [internalShowFolders, setInternalShowFolders] = useState(true); + const actualCurrentGlob = onFilterChange ? glob : internalGlob; + const showFolders = onFilterChange ? (showFoldersProp ?? true) : internalShowFolders; + + const updateFilter = useCallback((newGlob: string, newShowFolders: boolean) => { + if (onFilterChange) onFilterChange(newGlob, newShowFolders); + else { + setInternalGlob(newGlob); + setInternalShowFolders(newShowFolders); + } + }, [onFilterChange]); + + // โ”€โ”€ Available mounts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const [availableMounts, setAvailableMounts] = useState<string[]>([]); + useEffect(() => { + const headers: Record<string, string> = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + fetch('/api/vfs/mounts', { headers }) + .then(r => r.ok ? r.json() : []) + .then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name))) + .catch(() => { }); + }, [accessToken]); + + // โ”€โ”€ VFS Adapter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const { + nodes, + sorted, + loading, + error, + currentPath, + currentGlob, + updatePath, + updateMount, + fetchDir, + canGoUp, + goUp: rawGoUp, + breadcrumbs, + jailRoot, + isSearchMode + } = useVfsAdapter({ + mount, + pathProp, + glob: actualCurrentGlob, + showFolders, + accessToken, + index, + jail, + jailPath: pathProp, + sortBy: initialSort, + sortAsc: true, + includeSize, + searchQuery, + onPathChange, + onMountChange: (m) => { + setInternalMount(m); + if (onMountChange) onMountChange(m); + }, + onFetched: async (fetchedNodes, isSearch) => { + setReadmeContent(null); + if (index && !isSearch) { + const readmeNode = fetchedNodes.find(n => n.name.toLowerCase() === 'readme.md'); + if (readmeNode) { + const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; + const base = vfsUrl('get', mount, readmeNode.path); + const fileUrl = tokenParam ? `${base}?${tokenParam}` : base; + const fileRes = await fetch(fileUrl, { cache: 'no-cache' }); + if (fileRes.ok) { + const content = await fileRes.text(); + setReadmeContent(content); + } + } + } + } + }); + + // โ”€โ”€ View Mode & Zoom โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const [internalViewMode, setInternalViewMode] = useState<'list' | 'thumbs' | 'tree'>(() => { + if (autoSaveId) { + const saved = localStorage.getItem(`${autoSaveId}-viewMode`); + if (saved === 'list' || saved === 'thumbs' || saved === 'tree') return saved; + } + return initialViewMode; + }); + + const [internalMode, setInternalMode] = useState<'simple' | 'advanced'>(() => { + if (autoSaveId) { + const saved = localStorage.getItem(`${autoSaveId}-mode`); + if (saved === 'simple' || saved === 'advanced') return saved; + } + return mode; + }); + + const setViewMode = useCallback((m: 'list' | 'thumbs' | 'tree') => { + setInternalViewMode(m); + if (autoSaveId) localStorage.setItem(`${autoSaveId}-viewMode`, m); + }, [autoSaveId]); + + const setDisplayMode = useCallback((m: 'simple' | 'advanced') => { + setInternalMode(m); + if (autoSaveId) localStorage.setItem(`${autoSaveId}-mode`, m); + }, [autoSaveId]); + + const [splitDirection, setSplitDirectionState] = useState<'horizontal' | 'vertical'>(() => { + if (autoSaveId) { + const saved = localStorage.getItem(`${autoSaveId}-splitDir`); + if (saved === 'horizontal' || saved === 'vertical') return saved; + } + return typeof window !== 'undefined' && window.innerWidth < 768 ? 'vertical' : 'horizontal'; + }); + + const setSplitDirection = useCallback((m: 'horizontal' | 'vertical') => { + setSplitDirectionState(m); + if (autoSaveId) localStorage.setItem(`${autoSaveId}-splitDir`, m); + }, [autoSaveId]); + + const viewMode = internalViewMode; + const currentMode = internalMode; + const activeSplitSize = splitDirection === 'horizontal' ? splitSizeHorizontal : splitSizeVertical; + + const [thumbSize, setThumbSize] = useState(() => { + const v = localStorage.getItem('fb-thumb-size'); + return v ? Math.max(60, Math.min(200, Number(v))) : 80; + }); + const [fontSize, setFontSize] = useState(() => { + const v = localStorage.getItem('fb-font-size'); + return v ? Math.max(10, Math.min(18, Number(v))) : 14; + }); + + const zoomIn = () => { + if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); + else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; }); + }; + const zoomOut = () => { + if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); + else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; }); + }; + + // โ”€โ”€ Selection & Refs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const listRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + + const { + focusIdx, + setFocusIdx, + selected, + setSelected, + itemCount, + getNode, + handleItemClick, + clearSelection + } = useSelection({ + sorted, + canGoUp, + onSelect + }); + + // Dummy Sort controls for now since useVfsAdapter uses static sortBy + const [sortBy, setSortBy] = useState<SortKey>(initialSort); + const [sortAsc, setSortAsc] = useState(true); + const cycleSort = () => { + const keys: SortKey[] = ['name', 'ext', 'date', 'type']; + const i = keys.indexOf(sortBy); + if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); } + }; + + // โ”€โ”€ Previews โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const { + lightboxNode, + setLightboxNode, + textLightboxNode, + setTextLightboxNode, + iframeLightboxNode, + setIframeLightboxNode, + openPreview, + closeAllPreviews + } = useFilePreview({ allowLightbox, allowFileViewer }); + + // โ”€โ”€ Filter Dialog State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const [filterDialogOpen, setFilterDialogOpen] = useState(false); + const [tempGlob, setTempGlob] = useState(currentGlob); + const [tempShowFolders, setTempShowFolders] = useState(showFolders); + + const applyTempFilter = () => { + updateFilter(tempGlob, tempShowFolders); + setFilterDialogOpen(false); + setTimeout(() => containerRef.current?.focus(), 0); + }; + + const mediaGlob = Array.from(new Set([...IMAGE_EXTS, ...VIDEO_EXTS])).map(ext => `*.${ext}`).join(','); + const codeGlob = Array.from(CODE_EXTS).map(ext => `*.${ext}`).join(','); + + const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; + + // โ”€โ”€ Standalone scroll & grid helpers (shared by keyboard + selection hooks) โ”€โ”€ + + const scrollItemIntoView = useCallback((idx: number) => { + if (!listRef.current) return; + const items = listRef.current.querySelectorAll('[data-fb-idx]'); + const el = items[idx] as HTMLElement | undefined; + el?.scrollIntoView({ block: 'nearest' }); + }, []); + + const getGridCols = useCallback((): number => { + if (viewMode !== 'thumbs' || !listRef.current) return 1; + const style = getComputedStyle(listRef.current); + const cols = style.gridTemplateColumns.split(' ').length; + return Math.max(1, cols); + }, [viewMode]); + + // โ”€โ”€ Default Selection Handler (first so we get wrapped goUp) โ”€โ”€ + + const [pendingFileSelect, setPendingFileSelect] = useState<string | null>(null); + + const { goUp } = useDefaultSelectionHandler({ + sorted, + canGoUp, + rawGoUp, + currentPath, + loading, + viewMode, + autoFocus, + index, + isSearchMode, + initialFile, + allowFallback, + setFocusIdx, + setSelected, + onSelect, + pendingFileSelect, + setPendingFileSelect, + scrollItemIntoView, + containerRef, + listRef, + }); + + // โ”€โ”€ Default Keyboard Handler (uses wrapped goUp) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const { + searchOpen, + setSearchOpen, + searchDisplay, + searchBufferRef, + pendingSearchSelection, + setPendingSearchSelection, + handleKeyDown + } = useDefaultKeyboardHandler({ + focusIdx, + setFocusIdx, + selected, + setSelected, + itemCount, + getNode, + clearSelection, + canGoUp, + goUp, + updatePath, + openPreview, + viewMode, + setViewMode, + setDisplayMode, + currentGlob, + showFolders, + cycleSort, + setTempGlob, + setTempShowFolders, + setFilterDialogOpen, + containerRef, + scrollItemIntoView, + getGridCols, + autoFocus, + allowFallback, + currentPath, + onSearchQueryChange, + searchQuery, + isSearchMode, + onSelect, + sorted, + }); + + // โ”€โ”€ Default Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const { + selectedFile, + getFileUrl, + handleView, + handleDownload, + handleDownloadDir, + mediaNodes, + lightboxIdx, + lightboxPrev, + lightboxNext, + closeLightbox, + closeTextLightbox, + closeIframeLightbox, + handleDoubleClick, + handleLinkClick + } = useDefaultActions({ + mount, + mountProp, + pathProp, + accessToken, + selected, + sorted, + canGoUp, + setFocusIdx, + setSelected, + lightboxNode, + setLightboxNode, + textLightboxNode, + setTextLightboxNode, + iframeLightboxNode, + setIframeLightboxNode, + openPreview, + updatePath, + setPendingFileSelect, + containerRef, + getNode, + goUp, + }); + + return ( + <div + ref={containerRef} + data-testid="file-browser-panel" + tabIndex={viewMode === 'tree' ? undefined : 0} + className="fb-panel-container" + onKeyDown={handleKeyDown} + style={{ + display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, + border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden', + + fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none', + }} + > + <style>{` + @media (max-width: 767px) { + .fb-detail-pane { display: none !important; } + } + `}</style> + + {/* โ•โ•โ• Toolbar โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} + {showToolbar && ( + <FileBrowserToolbar + canGoUp={canGoUp} + goUp={goUp} + canChangeMount={!jail && canChangeMount} + availableMounts={availableMounts} + mount={mount} + updateMount={updateMount} + mountProp={mountProp} + pathProp={pathProp} + updatePath={updatePath} + breadcrumbs={breadcrumbs} + selectedNode={selected.length === 1 ? selected[0] : null} + selectedNodes={selected} + selectedFile={selectedFile} + handleView={handleView} + handleDownload={handleDownload} + allowDownload={allowDownload && selected.length > 0} + handleDownloadDir={handleDownloadDir} + allowDownloadDir={allowDownload} + sortBy={sortBy} + sortAsc={sortAsc} + cycleSort={cycleSort} + zoomIn={zoomIn} + zoomOut={zoomOut} + viewMode={viewMode} + setViewMode={setViewMode} + displayMode={currentMode} + setDisplayMode={setDisplayMode} + splitDirection={splitDirection} + setSplitDirection={setSplitDirection} + showExplorer={showExplorer} + onToggleExplorer={onToggleExplorer} + showPreview={showPreview} + onTogglePreview={onTogglePreview} + onFilterOpen={() => { + setTempGlob(currentGlob); + setTempShowFolders(showFolders); + setFilterDialogOpen(true); + }} + onSearchOpen={() => setSearchOpen(true)} + fontSize={fontSize} + isSearchMode={isSearchMode} + onClearSearch={() => onSearchQueryChange && onSearchQueryChange('')} + /> + )} + + {/* โ•โ•โ• Content โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} + {loading ? ( + <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}> + <Loader2 size={16} className="animate-spin" /> + <span style={{ fontSize: 14 }}><T>Loadingโ€ฆ</T></span> + </div> + ) : error ? ( + <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}> + <T>{error}</T> + </div> + ) : itemCount === 0 ? ( + <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}> + <T>Empty directory</T> + </div> + ) : ( + <div style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0 }}> + + <ResizablePanelGroup + direction={splitDirection} + onLayout={(sizes) => { + if (onLayoutChange) onLayoutChange(sizes, splitDirection); + }} + {...(activeSplitSize && activeSplitSize.length > 0 ? {} : { autoSaveId: autoSaveId ? `${autoSaveId}-split-${splitDirection}` : `pm-filebrowser-panel-layout-${splitDirection}` })} + className={`flex-1 flex overflow-hidden ${splitDirection === 'vertical' ? 'flex-col min-h-0' : 'flex-row min-w-0'}`} + > + {showExplorer && ( + <ResizablePanel defaultSize={activeSplitSize ? activeSplitSize[0] : 60} minSize={15} className="relative min-w-0 bg-white dark:bg-slate-800/50"> + <div className="w-full h-full flex flex-col min-h-[50px] min-w-0"> + {viewMode === 'tree' ? ( + <div className="flex-1 min-h-0 overflow-hidden pt-1"> + <FileTree + data={sorted} + canGoUp={canGoUp} + onGoUp={goUp} + selectedId={selected.length === 1 ? selected[0].path : undefined} + fontSize={fontSize} + fetchChildren={async (node: INode) => { + const clean = node.path.replace(/^\/+/, ''); + const base = vfsUrl('ls', mount, clean); + const url = `${base}?includeSize=true`; + const headers: Record<string, string> = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }} + onSelectionChange={(nodes) => { + setSelected(nodes); + }} + onSelect={(n) => { + setSelected([n]); + }} + onActivate={(n) => { + if (getMimeCategory(n) === 'dir') { + updatePath(n.path || n.name); + } else { + openPreview(n); + } + }} + /> + </div> + ) : viewMode === 'list' ? ( + <div className="flex-1 min-h-0 overflow-hidden pt-1 flex flex-col w-full h-full"> + <FileListView + listRef={listRef} + sorted={sorted} + canGoUp={canGoUp} + goUp={goUp} + focusIdx={focusIdx} + setFocusIdx={setFocusIdx} + selected={selected} + onItemClick={handleItemClick} + onItemDoubleClick={handleDoubleClick} + fontSize={fontSize} + mode={currentMode} + searchBuffer={isSearchMode ? (searchQuery || searchDisplay) : searchDisplay} + isSearchMode={isSearchMode} + /> + </div> + ) : ( + <div className="flex-1 flex flex-col min-h-0 overflow-hidden w-full h-full pt-1"> + <FileGridView + listRef={listRef} + sorted={sorted} + canGoUp={canGoUp} + goUp={goUp} + focusIdx={focusIdx} + setFocusIdx={setFocusIdx} + selected={selected} + onItemClick={handleItemClick} + onItemDoubleClick={handleDoubleClick} + thumbSize={thumbSize} + mount={mount} + tokenParam={tokenParam} + fontSize={fontSize} + isSearchMode={isSearchMode} + /> + </div> + )} + </div> + </ResizablePanel> + )} + + {/* Right Pane conditionally renders if preview or fallback exists */} + {showPreview && ((!showExplorer && selected.length === 1) || (allowPreview && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir') || (selected.length === 0 && readmeContent) || (allowPreview && selectedReadmeContent) || (allowPreview && allowFallback && selected.length === 1 && getMimeCategory(selected[0]) === 'dir')) && ( + <> + {showExplorer && <ResizableHandle withHandle />} + <ResizablePanel defaultSize={activeSplitSize && activeSplitSize.length > 1 ? activeSplitSize[1] : (showExplorer ? 40 : 100)} minSize={15} className="relative min-w-0 bg-card/30"> + <div className="w-full h-full flex flex-col min-h-[50px] min-w-0"> + {((!showExplorer && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir') || (allowPreview && selected.length === 1 && getMimeCategory(selected[0]) !== 'dir')) ? ( + <div className="fb-readme-pane shrink-0 border-t md:border-t-0 border-border overflow-hidden w-full h-full relative flex flex-1 flex-col min-h-0"> + {renderFileViewer({ + selected: selected[0], + url: getFileUrl(selected[0]), + fileName: selected[0].name, + inline: true, + isOpen: true, + onClose: () => { }, + onLinkClick: (href, e) => handleLinkClick(href, e, selected[0].parent || '/') + })} + </div> + ) : ((selected.length === 0 && readmeContent) || (allowPreview && selectedReadmeContent)) ? ( + <div className="fb-readme-pane shrink-0 p-6 border-t md:border-t-0 border-border overflow-y-auto flex-1 w-full h-full"> + <MarkdownRenderer + content={selectedReadmeContent || readmeContent || ''} + baseUrl={ + selectedReadmeContent && selected.length === 1 + ? vfsUrl('get', mount, selected[0].path) + '/' + : vfsUrl('get', mount, currentPath) + '/' + } + onLinkClick={(href, e) => { + const basePath = (selectedReadmeContent && selected.length === 1) + ? (selected[0].parent || '/') + : currentPath; + handleLinkClick(href, e, basePath); + }} + /> + </div> + ) : (allowPreview && allowFallback && selected.length === 1 && getMimeCategory(selected[0]) === 'dir') ? ( + <div className="fb-readme-pane flex shrink-0 border-t md:border-t-0 border-border flex-1 flex-col min-h-0 overflow-hidden relative w-full h-full"> + <FileBrowserPanel + mount={mount} + path={selected[0].path} + viewMode="thumbs" + showToolbar={false} + mode="simple" + jail={true} + allowFallback={false} + index={false} + autoFocus={false} + showStatusBar={false} + /> + </div> + ) : null} + </div> + </ResizablePanel> + </> + )} + </ResizablePanelGroup> + + {/* Detail panel (advanced, desktop only) */} + + + {/* Detail panel (advanced, desktop only) */} + {mode === 'advanced' && ( + <FileDetailPanel file={selected.length === 1 ? selected[0] : null} fileUrl={selected.length === 1 ? getFileUrl(selected[0]) : ''} /> + )} + </div> + )} + + {showStatusBar && <div style={{ + padding: '4px 10px', fontSize: 13, borderTop: '2px solid var(--border)', + color: 'var(--muted-foreground)', display: 'flex', justifyContent: 'space-between', + background: 'var(--muted)', width: '100%', overflow: 'hidden', gap: '8px' + }}> + <span + title={`${sorted.length} ${sorted.length !== 1 ? 'items' : 'item'} ยท ${formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}${selectedFile ? ` ยท ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}`} + style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} + > + {sorted.length} <T>{sorted.length !== 1 ? 'items' : 'item'}</T> + {' ยท '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))} + {selectedFile ? ` ยท ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''} + </span> + <span + title={`${mount}:${currentPath || '/'}`} + style={{ + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + flexShrink: 1, textAlign: 'right', minWidth: 50, maxWidth: '50%' + }} + > + {mount}:{currentPath || '/'} + </span> + </div>} + + {/* โ•โ•โ• Lightboxes โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */} + <ImageLightbox + isOpen={!!lightboxNode} + onClose={closeLightbox} + imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''} + imageTitle={lightboxNode?.name || ''} + currentIndex={lightboxIdx} + totalCount={mediaNodes.length} + onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()} + showPrompt={false} + /> + <LightboxText + isOpen={!!textLightboxNode} + onClose={closeTextLightbox} + url={textLightboxNode ? getFileUrl(textLightboxNode) : ''} + fileName={textLightboxNode?.name || ''} + /> + <LightboxIframe + isOpen={!!iframeLightboxNode} + onClose={closeIframeLightbox} + url={iframeLightboxNode ? getFileUrl(iframeLightboxNode) : ''} + fileName={iframeLightboxNode?.name || ''} + /> + + {/* โ”€โ”€ Dialogs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {filterDialogOpen && ( + <Dialog open={filterDialogOpen} onOpenChange={(open) => { + if (!open) { + setFilterDialogOpen(false); + setTimeout(() => containerRef.current?.focus(), 0); + } + }}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle><T>Filter Current View</T></DialogTitle> + <DialogDescription> + <T>Enter a list of comma-separated wildcard matcher expressions (e.g., *.jpg, *.png) or use a preset below.</T> + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="flex items-center justify-between"> + <Label htmlFor="showFolders" className="flex flex-col gap-1"> + <span><T>Show Folders</T></span> + <span className="font-normal text-xs text-muted-foreground"><T>Keep subdirectories visible alongside matched files</T></span> + </Label> + <Switch + id="showFolders" + checked={tempShowFolders} + onCheckedChange={setTempShowFolders} + /> + </div> + <div className="grid gap-2"> + <Label htmlFor="globPattern">Glob Pattern</Label> + <Input + id="globPattern" + value={tempGlob} + onChange={(e) => setTempGlob(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + applyTempFilter(); + } + }} + autoFocus + placeholder="*.*" + /> + </div> + <div className="flex flex-wrap gap-2 mt-2"> + <Badge variant="outline" className="cursor-pointer hover:bg-muted" onClick={() => setTempGlob('*.*')}> + All Files (*.*) + </Badge> + <Badge variant="outline" className="cursor-pointer hover:bg-muted" onClick={() => setTempGlob(mediaGlob)}> + Media + </Badge> + <Badge variant="outline" className="cursor-pointer hover:bg-muted" onClick={() => setTempGlob(codeGlob)}> + Code + </Badge> + </div> + </div> + <div className="flex justify-end gap-3 w-full"> + <Button variant="outline" onClick={() => { + setFilterDialogOpen(false); + setTimeout(() => containerRef.current?.focus(), 0); + }}> + Cancel + </Button> + <Button onClick={applyTempFilter}> + Apply Filter + </Button> + </div> + </DialogContent> + </Dialog> + )} + + {/* Search dialog */} + {searchOpen && ( + <SearchDialog + mount={mount} + currentPath={currentPath} + accessToken={accessToken} + onNavigate={(node: INode) => { + const isDir = getMimeCategory(node) === 'dir'; + + if (isDir) { + updatePath(node.path.startsWith('/') ? node.path : `/${node.path}`); + } else { + const parentPath = node.parent || '/'; + + const currentTarget = parentPath.startsWith('/') ? parentPath : `/${parentPath}`; + const normalizedCurrent = currentPath.replace(/\/+$/, '') || '/'; + const normalizedTarget = currentTarget.replace(/\/+$/, '') || '/'; + + if (normalizedTarget !== normalizedCurrent) { + setPendingSearchSelection(node.name); + updatePath(currentTarget); + } else { + const idx = sorted.findIndex(n => n.name === node.name); + if (idx >= 0) { + const focusIndex = canGoUp ? idx + 1 : idx; + setFocusIdx(focusIndex); + const itemNode = sorted[idx]; + if (itemNode) { + const newlySelected = [itemNode]; + setSelected(newlySelected); + if (onSelect) { + onSelect(newlySelected); + } + requestAnimationFrame(() => scrollItemIntoView(focusIndex)); + } + } + } + } + }} + onClose={() => { + setSearchOpen(false); + setTimeout(() => { + if (viewMode === 'tree') { + listRef.current?.focus(); + } else { + containerRef.current?.focus(); + } + }, 0); + }} + /> + )} + </div> + ); +}; + +export { FileBrowserPanel }; +export default FileBrowserPanel; diff --git a/packages/ui/src/apps/filebrowser/FileTree.tsx b/packages/ui/src/apps/filebrowser/FileTree.tsx new file mode 100644 index 00000000..6781deb5 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/FileTree.tsx @@ -0,0 +1,619 @@ +import React, { useMemo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { ChevronRight, Folder, FolderOpen, File, ArrowUp, Loader2 } from "lucide-react"; +import { INode } from "@/modules/storage/types"; +import { getMimeCategory } from "@/modules/storage/helpers"; +import { cn } from "@/lib/utils"; + +export interface FileTreeProps { + data: INode[]; + onSelect?: (node: INode) => void; + /** Fires whenever multi-select changes โ€” passes all currently selected nodes */ + onSelectionChange?: (nodes: INode[]) => void; + onActivate?: (node: INode) => void; + onGoUp?: () => void; + canGoUp?: boolean; + selectedId?: string; + className?: string; + /** Async loader for folder children. If provided, folders become expandable inline. */ + fetchChildren?: (node: INode) => Promise<INode[]>; + fontSize?: number; +} + +type TreeRow = { + id: string; + name: string; + node: INode; + isDir: boolean; + isNavUp: boolean; + depth: number; + parentId: string | null; + expanded: boolean; + loading: boolean; +}; + +interface ExpandState { + expanded: boolean; + children: INode[]; + loading: boolean; +} + +function sortDirsFirst(nodes: INode[]): INode[] { + return [...nodes].sort((a, b) => { + const aDir = getMimeCategory(a) === 'dir' ? 0 : 1; + const bDir = getMimeCategory(b) === 'dir' ? 0 : 1; + if (aDir !== bDir) return aDir - bDir; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); +} + +function buildRows( + nodes: INode[], + canGoUp: boolean, + expandMap: Record<string, ExpandState>, + depth: number = 0, + parentId: string | null = null +): TreeRow[] { + const rows: TreeRow[] = []; + if (depth === 0 && canGoUp) { + rows.push({ + id: '__nav_up__', + name: '..', + node: { name: '..', path: '..', type: 'dir', mime: 'inode/directory' } as INode, + isDir: true, + isNavUp: true, + depth: 0, + parentId: null, + expanded: false, + loading: false, + }); + } + for (const n of nodes) { + const isDir = getMimeCategory(n) === 'dir'; + const state = expandMap[n.path]; + const expanded = state?.expanded ?? false; + const loading = state?.loading ?? false; + rows.push({ + id: n.path, + name: n.name, + node: n, + isDir, + isNavUp: false, + depth, + parentId, + expanded, + loading, + }); + if (isDir && expanded && state?.children?.length) { + const childRows = buildRows( + sortDirsFirst(state.children), + false, + expandMap, + depth + 1, + n.path + ); + rows.push(...childRows); + } + } + return rows; +} + +/** Search rows: try startsWith first, fall back to includes */ +function findMatchIdx(rows: TreeRow[], str: string, startFrom: number, direction: 1 | -1 = 1): number { + const count = rows.length; + // Pass 1: startsWith + for (let i = 0; i < count; i++) { + const idx = ((startFrom + i * direction) % count + count) % count; + if (rows[idx].name.toLowerCase().startsWith(str)) return idx; + } + // Pass 2: includes + for (let i = 0; i < count; i++) { + const idx = ((startFrom + i * direction) % count + count) % count; + if (rows[idx].name.toLowerCase().includes(str)) return idx; + } + return -1; +} + +export const FileTree = React.forwardRef<HTMLDivElement, FileTreeProps>( + ({ data, onSelect, onSelectionChange, onActivate, onGoUp, canGoUp = false, selectedId, className, fetchChildren, fontSize = 14 }, forwardedRef) => { + const [expandMap, setExpandMap] = useState<Record<string, ExpandState>>({}); + const rows = useMemo(() => buildRows(data, canGoUp, expandMap), [data, canGoUp, expandMap]); + const [focusIdx, setFocusIdx] = useState(0); + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); + const anchorIdx = useRef<number>(0); // anchor for shift-click range selection + const containerRef = useRef<HTMLDivElement>(null); + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); + + // Type-ahead search + const searchBuf = useRef(''); + const searchTimer = useRef<any>(null); + const [searchDisplay, setSearchDisplay] = useState(''); // mirrors searchBuf for rendering + + // Merge forwarded ref + const setRef = useCallback((el: HTMLDivElement | null) => { + (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el; + if (typeof forwardedRef === 'function') forwardedRef(el); + else if (forwardedRef) (forwardedRef as React.MutableRefObject<HTMLDivElement | null>).current = el; + }, [forwardedRef]); + + // Reset expand state when root data changes + useEffect(() => { + setExpandMap({}); + setSelectedIds(new Set()); + }, [data]); + + // Sync selectedId prop from parent + useEffect(() => { + if (selectedId) { + // If it's already selected, do nothing + setSelectedIds(prev => { + if (prev.size === 1 && prev.has(selectedId)) return prev; + return new Set([selectedId]); + }); + const idx = rows.findIndex(r => r.id === selectedId); + if (idx >= 0) { + setFocusIdx(idx); + } + } else if (selectedId === undefined && data.length === 0) { + setSelectedIds(new Set()); + } + }, [selectedId, rows, data.length]); + + // Auto-focus first real item when root data changes + const prevDataRef = useRef(data); + useLayoutEffect(() => { + if (data === prevDataRef.current && rows.length > 0) { + // If data hasn't changed but rows has, this is an expand/collapse operation. Don't reset. + return; + } + prevDataRef.current = data; + + if (rows.length === 0) return; + + // Give priority to selectedId provided by parent (e.g. return focus matching target) + if (selectedId) { + const existingIdx = rows.findIndex(r => r.id === selectedId); + if (existingIdx >= 0) { + containerRef.current?.focus({ preventScroll: true }); + return; + } + } + + const startIdx = canGoUp ? 1 : 0; + const idx = startIdx < rows.length ? startIdx : 0; + setFocusIdx(idx); + anchorIdx.current = idx; + containerRef.current?.focus({ preventScroll: true }); + }, [data, canGoUp, rows, selectedId]); + + // Scroll focused item into view + useEffect(() => { + rowRefs.current[focusIdx]?.scrollIntoView({ block: 'nearest' }); + }, [focusIdx]); + + // Stable ref for onSelectionChange to avoid infinite loop + // (inline arrow in parent JSX creates new ref every render) + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; + const rowsRef = useRef(rows); + rowsRef.current = rows; + + // Notify parent when selection changes (only on selectedIds change, NOT rows) + useEffect(() => { + if (!onSelectionChangeRef.current) return; + const currentRows = rowsRef.current; + const selectedNodes = currentRows.filter(r => selectedIds.has(r.id) && !r.isNavUp).map(r => r.node); + onSelectionChangeRef.current(selectedNodes); + }, [selectedIds]); + + // โ”€โ”€ Expand / Collapse โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const toggleExpand = useCallback(async (row: TreeRow) => { + if (!row.isDir || row.isNavUp || !fetchChildren) return; + const path = row.node.path; + const current = expandMap[path]; + + if (current?.expanded) { + setExpandMap(prev => ({ + ...prev, + [path]: { ...prev[path], expanded: false }, + })); + } else if (current?.children?.length) { + setExpandMap(prev => ({ + ...prev, + [path]: { ...prev[path], expanded: true }, + })); + } else { + setExpandMap(prev => ({ + ...prev, + [path]: { expanded: true, children: [], loading: true }, + })); + try { + const children = await fetchChildren(row.node); + setExpandMap(prev => ({ + ...prev, + [path]: { expanded: true, children, loading: false }, + })); + } catch { + setExpandMap(prev => ({ + ...prev, + [path]: { expanded: false, children: [], loading: false }, + })); + } + } + }, [expandMap, fetchChildren]); + + /** Batch expand/collapse all selected folders */ + const toggleExpandSelected = useCallback(async () => { + if (!fetchChildren || selectedIds.size === 0) return; + const selectedDirs = rows.filter(r => selectedIds.has(r.id) && r.isDir && !r.isNavUp); + if (selectedDirs.length === 0) return; + + // If ANY are collapsed, expand all. If ALL expanded, collapse all. + const anyCollapsed = selectedDirs.some(r => !r.expanded); + + if (!anyCollapsed) { + // Collapse all + setExpandMap(prev => { + const next = { ...prev }; + for (const r of selectedDirs) { + next[r.node.path] = { ...next[r.node.path], expanded: false }; + } + return next; + }); + } else { + // Expand all (fetch as needed) + for (const r of selectedDirs) { + if (!r.expanded) { + await toggleExpand(r); + } + } + } + }, [fetchChildren, selectedIds, rows, toggleExpand]); + + const collapseNode = useCallback((row: TreeRow) => { + if (!row.isDir || row.isNavUp) return; + setExpandMap(prev => ({ + ...prev, + [row.node.path]: { ...prev[row.node.path], expanded: false }, + })); + }, []); + + // Activate handler (enter / double-click) + const activate = useCallback((row: TreeRow) => { + if (row.isNavUp) { + onGoUp?.(); + } else { + onActivate?.(row.node); + } + }, [onActivate, onGoUp]); + + // โ”€โ”€ Selection helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + /** Simple select โ€” replaces selection with single item */ + const selectRow = useCallback((idx: number) => { + setFocusIdx(idx); + anchorIdx.current = idx; + const row = rows[idx]; + if (!row || row.isNavUp) { + setSelectedIds(new Set()); + return; + } + setSelectedIds(new Set([row.id])); + onSelect?.(row.node); + }, [rows, onSelect]); + + /** Ctrl+click โ€” toggle item in/out of selection */ + const toggleSelectRow = useCallback((idx: number) => { + setFocusIdx(idx); + anchorIdx.current = idx; + const row = rows[idx]; + if (!row || row.isNavUp) return; + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(row.id)) next.delete(row.id); + else next.add(row.id); + return next; + }); + }, [rows]); + + /** Shift+click โ€” range select from anchor to idx */ + const rangeSelectTo = useCallback((idx: number) => { + setFocusIdx(idx); + const start = Math.min(anchorIdx.current, idx); + const end = Math.max(anchorIdx.current, idx); + const ids = new Set<string>(); + for (let i = start; i <= end; i++) { + const r = rows[i]; + if (r && !r.isNavUp) ids.add(r.id); + } + setSelectedIds(ids); + }, [rows]); + + // Click handler + const handleClick = useCallback((idx: number, e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + toggleSelectRow(idx); + } else if (e.shiftKey) { + rangeSelectTo(idx); + } else { + selectRow(idx); + } + containerRef.current?.focus({ preventScroll: true }); + }, [selectRow, toggleSelectRow, rangeSelectTo]); + + // Keyboard handler + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.altKey) return; + + const count = rows.length; + if (count === 0) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + if (searchBuf.current) { + // Cycle to next match + const found = findMatchIdx(rows, searchBuf.current, focusIdx + 1, 1); + if (found >= 0) selectRow(found); + } else { + const next = focusIdx < count - 1 ? focusIdx + 1 : 0; + if (e.shiftKey) rangeSelectTo(next); + else selectRow(next); + } + break; + } + case 'ArrowUp': { + e.preventDefault(); + if (searchBuf.current) { + // Cycle to previous match + const found = findMatchIdx(rows, searchBuf.current, focusIdx - 1 + count, -1); + if (found >= 0) selectRow(found); + } else { + const prev = focusIdx > 0 ? focusIdx - 1 : count - 1; + if (e.shiftKey) rangeSelectTo(prev); + else selectRow(prev); + } + break; + } + case 'ArrowRight': { + e.preventDefault(); + // Multi-select: expand all selected collapsed dirs + if (fetchChildren && selectedIds.size > 1) { + const selectedDirs = rows.filter(r => selectedIds.has(r.id) && r.isDir && !r.isNavUp && !r.expanded); + if (selectedDirs.length > 0) { + for (const r of selectedDirs) toggleExpand(r); + break; + } + } + // Single-item behavior + const rowR = rows[focusIdx]; + if (!rowR) break; + if (rowR.isDir && !rowR.isNavUp && fetchChildren) { + if (!rowR.expanded) { + toggleExpand(rowR); + } else { + if (focusIdx < count - 1) selectRow(focusIdx + 1); + } + } else { + if (focusIdx < count - 1) selectRow(focusIdx + 1); + } + break; + } + case 'ArrowLeft': { + e.preventDefault(); + // Clean stale IDs (children of collapsed folders no longer in view) + const visibleIds = new Set(rows.map(r => r.id)); + const cleanIds = new Set([...selectedIds].filter(id => visibleIds.has(id))); + if (cleanIds.size !== selectedIds.size) setSelectedIds(cleanIds); + + // Multi-select: collapse all selected expanded dirs + if (fetchChildren && cleanIds.size > 1) { + const selectedDirs = rows.filter(r => cleanIds.has(r.id) && r.isDir && !r.isNavUp && r.expanded); + if (selectedDirs.length > 0) { + for (const r of selectedDirs) collapseNode(r); + break; + } + } + // Single-item behavior + const rowL = rows[focusIdx]; + if (!rowL) break; + if (rowL.isDir && !rowL.isNavUp && rowL.expanded && fetchChildren) { + collapseNode(rowL); + } else if (rowL.parentId) { + const parentIdx = rows.findIndex(r => r.id === rowL.parentId); + if (parentIdx >= 0) { + const parentRow = rows[parentIdx]; + if (parentRow) collapseNode(parentRow); + selectRow(parentIdx); + } + } else if (canGoUp) { + onGoUp?.(); + } + break; + } + case 'Enter': { + e.preventDefault(); + const row = rows[focusIdx]; + if (row) activate(row); + break; + } + case 'Backspace': { + e.preventDefault(); + if (searchBuf.current.length > 0) { + searchBuf.current = searchBuf.current.slice(0, -1); + if (searchBuf.current.length > 0) { + const found = findMatchIdx(rows, searchBuf.current, 0, 1); + if (found >= 0) selectRow(found); + } + } else if (canGoUp) { + onGoUp?.(); + } + break; + } + case 'Home': { + e.preventDefault(); + if (e.shiftKey) rangeSelectTo(0); + else selectRow(0); + break; + } + case 'End': { + e.preventDefault(); + if (e.shiftKey) rangeSelectTo(count - 1); + else selectRow(count - 1); + break; + } + case ' ': { + e.preventDefault(); + if (fetchChildren && selectedIds.size > 0) { + // Batch expand/collapse selected dirs + const hasSelectedDirs = rows.some(r => selectedIds.has(r.id) && r.isDir && !r.isNavUp); + if (hasSelectedDirs) { + toggleExpandSelected(); + break; + } + } + // Fallback: toggle single focused folder or activate file + const row = rows[focusIdx]; + if (row?.isDir && !row.isNavUp && fetchChildren) { + toggleExpand(row); + } else if (row) { + activate(row); + } + break; + } + case 'a': { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + // Select all + const all = new Set<string>(); + for (const r of rows) { + if (!r.isNavUp) all.add(r.id); + } + setSelectedIds(all); + } + break; + } + case 'Escape': { + e.preventDefault(); + if (searchBuf.current) { + searchBuf.current = ''; + setSearchDisplay(''); + } else { + setSelectedIds(new Set()); + } + break; + } + default: { + if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { + const char = e.key.toLowerCase(); + searchBuf.current += char; + setSearchDisplay(searchBuf.current); + + if (searchTimer.current) clearTimeout(searchTimer.current); + searchTimer.current = setTimeout(() => { searchBuf.current = ''; setSearchDisplay(''); }, 10000); + + const str = searchBuf.current; + const found = findMatchIdx(rows, str, focusIdx, 1); + if (found >= 0) selectRow(found); + } + break; + } + } + }, [focusIdx, rows, canGoUp, onGoUp, activate, selectRow, toggleExpand, collapseNode, fetchChildren, rangeSelectTo, selectedIds, toggleExpandSelected]); + + return ( + <div + ref={setRef} + data-testid="file-tree" + className={cn("w-full h-full min-h-0 overflow-y-auto outline-none fb-tree-container p-1", className)} + tabIndex={0} + onKeyDown={handleKeyDown} + > + {rows.map((row, idx) => { + const isSelected = selectedIds.has(row.id); + const isFocused = focusIdx === idx; + return ( + <div + key={row.id} + data-testid="file-tree-node" + data-node-id={row.id} + ref={(el) => { rowRefs.current[idx] = el; }} + className={cn( + "flex items-center gap-1 py-0.5 cursor-pointer select-none rounded group", + isSelected && !isFocused && "bg-blue-100 dark:bg-accent/60 text-blue-900 dark:text-accent-foreground", + isFocused && "bg-blue-50 dark:bg-accent/80 text-foreground ring-1 ring-blue-400 dark:ring-ring", + !isSelected && !isFocused && "hover:bg-accent/40", + )} + style={{ fontSize: fontSize, paddingLeft: `${row.depth * 16 + 4}px`, paddingRight: 8 }} + onClick={(e) => handleClick(idx, e)} + onDoubleClick={() => activate(row)} + > + {/* Expand/collapse chevron */} + {row.isDir && !row.isNavUp && fetchChildren ? ( + <button + type="button" + className="flex items-center justify-center w-4 h-4 shrink-0 text-muted-foreground hover:text-foreground transition-colors" + onClick={(e) => { + e.stopPropagation(); + toggleExpand(row); + }} + tabIndex={-1} + > + {row.loading ? ( + <Loader2 size={12} className="animate-spin" /> + ) : ( + <ChevronRight + size={14} + className={cn( + "transition-transform duration-150", + row.expanded && "rotate-90" + )} + /> + )} + </button> + ) : ( + <span className="w-4 shrink-0" /> + )} + + {/* Icon */} + <div className="flex items-center justify-center text-muted-foreground shrink-0"> + {row.isNavUp ? ( + <ArrowUp size={16} /> + ) : row.isDir ? ( + row.expanded ? ( + <FolderOpen size={16} className="text-blue-400 fill-blue-400/20" /> + ) : ( + <Folder size={16} className="text-blue-400 fill-blue-400/20" /> + ) + ) : ( + <File size={16} /> + )} + </div> + + {/* Name โ€” highlight type-ahead match on focused row */} + <span className="truncate flex-1"> + {(() => { + if (!isFocused || !searchDisplay) return row.name; + const lower = row.name.toLowerCase(); + const pos = lower.startsWith(searchDisplay) ? 0 : lower.indexOf(searchDisplay); + if (pos < 0) return row.name; + return ( + <> + {pos > 0 && row.name.slice(0, pos)} + <span className="bg-amber-200/80 text-amber-900 border-b border-amber-500 dark:bg-sky-500/30 dark:text-sky-200 dark:border-sky-400"> + {row.name.slice(pos, pos + searchDisplay.length)} + </span> + {row.name.slice(pos + searchDisplay.length)} + </> + ); + })()} + </span> + </div> + ); + })} + </div> + ); + } +); + +FileTree.displayName = 'FileTree'; diff --git a/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx b/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx index 99021867..80731f91 100644 --- a/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx +++ b/packages/ui/src/apps/filebrowser/LayoutToolbar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Columns2, Square, Plus, Link, Unlink } from 'lucide-react'; +import { T, translate } from '@/i18n'; import { useFileBrowser, type LayoutMode } from './FileBrowserContext'; const LayoutToolbar: React.FC = () => { @@ -19,8 +20,8 @@ const LayoutToolbar: React.FC = () => { }; const modes: { key: LayoutMode; label: string; icon: React.ReactNode }[] = [ - { key: 'single', label: 'Single', icon: <Square size={14} /> }, - { key: 'dual', label: 'Dual', icon: <Columns2 size={14} /> }, + { key: 'single', label: translate('Single'), icon: <Square size={14} /> }, + { key: 'dual', label: translate('Dual'), icon: <Columns2 size={14} /> }, ]; return ( @@ -33,14 +34,14 @@ const LayoutToolbar: React.FC = () => { gap: 6, }}> <span style={{ fontSize: 12, color: 'var(--muted-foreground)', fontWeight: 600, marginRight: 4 }}> - Layout + <T>Layout</T> </span> {modes.map(m => ( <button key={m.key} onClick={() => setLayout(m.key)} style={layout === m.key ? btnActive : btnBase} - title={`${m.label} pane`} + title={translate(`${m.label} pane`)} > {m.icon} <span>{m.label}</span> @@ -52,10 +53,10 @@ const LayoutToolbar: React.FC = () => { <button onClick={() => setLinked(!linked)} style={linked ? btnActive : btnBase} - title={linked ? 'Unlink panes (left stops mirroring to right)' : 'Link panes (left folder nav mirrors to right)'} + title={linked ? translate('Unlink panes (left stops mirroring to right)') : translate('Link panes (left folder nav mirrors to right)')} > {linked ? <Link size={14} /> : <Unlink size={14} />} - <span>{linked ? 'Linked' : 'Link'}</span> + <span>{linked ? translate('Linked') : translate('Link')}</span> </button> )} @@ -65,10 +66,10 @@ const LayoutToolbar: React.FC = () => { <button onClick={() => addPanel(activeSide)} style={btnBase} - title={`Add tab to ${activeSide} side`} + title={translate(`Add tab to ${activeSide} side`)} > <Plus size={14} /> - <span className="hidden sm:inline">New Tab</span> + <span className="hidden sm:inline"><T>New Tab</T></span> </button> </div> ); diff --git a/packages/ui/src/apps/filebrowser/PanelSide.tsx b/packages/ui/src/apps/filebrowser/PanelSide.tsx index d8dcdd70..eb4299d3 100644 --- a/packages/ui/src/apps/filebrowser/PanelSide.tsx +++ b/packages/ui/src/apps/filebrowser/PanelSide.tsx @@ -1,5 +1,6 @@ import React, { useCallback } from 'react'; import { X } from 'lucide-react'; +import { translate } from '@/i18n'; import { useFileBrowser, type Side } from './FileBrowserContext'; import FileBrowserPanel from './FileBrowserPanel'; @@ -12,6 +13,7 @@ const PanelSide: React.FC<PanelSideProps> = ({ side }) => { leftPanels, rightPanels, activeSide, activePanelIdx, setActivePanel, updatePanel, removePanel, + viewMode, showToolbar, initialFile, mode, index, } = useFileBrowser(); const panels = side === 'left' ? leftPanels : rightPanels; @@ -33,6 +35,18 @@ const PanelSide: React.FC<PanelSideProps> = ({ side }) => { updatePanel(side, currentIdx, { selected: node }); }, [side, currentIdx, updatePanel]); + const handleFilterChange = useCallback((glob: string, showFolders: boolean) => { + updatePanel(side, currentIdx, { glob, showFolders }); + }, [side, currentIdx, updatePanel]); + + const handleToggleExplorer = useCallback(() => { + updatePanel(side, currentIdx, { showExplorer: !panel.showExplorer }); + }, [side, currentIdx, updatePanel, panel.showExplorer]); + + const handleTogglePreview = useCallback(() => { + updatePanel(side, currentIdx, { showPreview: panel.showPreview === false ? true : false }); + }, [side, currentIdx, updatePanel, panel.showPreview]); + return ( <div onClick={() => { if (!isActiveSide) setActivePanel(side, currentIdx); }} @@ -75,7 +89,7 @@ const PanelSide: React.FC<PanelSideProps> = ({ side }) => { {panels.length > 1 && ( <button onClick={(e) => { e.stopPropagation(); removePanel(side, i); }} - title="Close tab" + title={translate("Close tab")} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'inherit', opacity: 0.5, @@ -98,16 +112,27 @@ const PanelSide: React.FC<PanelSideProps> = ({ side }) => { mount={panel.mount} path={panel.path} glob={panel.glob} - mode="advanced" - viewMode="list" - showToolbar={true} + searchQuery={panel.searchQuery} + mode={mode} + viewMode={viewMode} + showToolbar={showToolbar} canChangeMount={true} allowFileViewer={true} allowLightbox={true} allowDownload={true} + showFolders={panel.showFolders} + showExplorer={panel.showExplorer} + showPreview={panel.showPreview !== false} onPathChange={handlePathChange} onMountChange={handleMountChange} onSelect={handleSelect} + onFilterChange={handleFilterChange} + onToggleExplorer={handleToggleExplorer} + onTogglePreview={handleTogglePreview} + onSearchQueryChange={(q) => updatePanel(side, currentIdx, { searchQuery: q })} + initialFile={side === 'left' ? initialFile : undefined} + index={index} + autoSaveId={`fb-${side}-${panel.id}`} /> </div> </div> diff --git a/packages/ui/src/apps/filebrowser/SearchDialog.tsx b/packages/ui/src/apps/filebrowser/SearchDialog.tsx new file mode 100644 index 00000000..c9f82133 --- /dev/null +++ b/packages/ui/src/apps/filebrowser/SearchDialog.tsx @@ -0,0 +1,233 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { Search, Loader2, X, ArrowRight, ExternalLink, LayoutList, CheckSquare } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import type { INode } from '@/modules/storage/types'; +import { getMimeCategory, vfsUrl } from '@/modules/storage/helpers'; +import { NodeIcon } from '@/modules/storage/ThumbPreview'; +import { T, translate } from '@/i18n'; +import { useFileBrowser } from './FileBrowserContext'; + +// โ”€โ”€ Props โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SearchDialogProps { + mount: string; + currentPath: string; + accessToken?: string; + onNavigate: (node: INode) => void; + onClose: () => void; +} + +// โ”€โ”€ Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const SearchDialog: React.FC<SearchDialogProps> = ({ + mount, currentPath, accessToken, onNavigate, onClose, +}) => { + const { activeSide, activePanelIdx, activePanel, updatePanel } = useFileBrowser(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState<INode[]>([]); + const [loading, setLoading] = useState(false); + const [truncated, setTruncated] = useState(false); + const [focusIdx, setFocusIdx] = useState(0); + const [searched, setSearched] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + const listRef = useRef<HTMLDivElement>(null); + const debounceRef = useRef<any>(null); + + // Auto-focus input on mount + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 50); + }, []); + + // Debounced search + const doSearch = useCallback(async (q: string) => { + if (q.trim().length < 2) { + setResults([]); + setSearched(false); + return; + } + + setLoading(true); + setSearched(true); + try { + const headers: Record<string, string> = {}; + if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; + const cleanPath = currentPath.replace(/^\/+/, ''); + const ftsParam = activePanel.searchFullText ? '&fts=1' : ''; + const url = cleanPath + ? `${vfsUrl('search', mount, cleanPath)}?q=${encodeURIComponent(q.trim())}&maxResults=200${ftsParam}` + : `${vfsUrl('search', mount)}?q=${encodeURIComponent(q.trim())}&maxResults=200${ftsParam}`; + const res = await fetch(url, { headers }); + if (res.ok) { + const data = await res.json(); + setResults(data.results || []); + setTruncated(data.truncated || false); + setFocusIdx(0); + } else { + setResults([]); + } + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, [mount, currentPath, accessToken]); + + const handleInput = useCallback((val: string) => { + setQuery(val); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(val), 300); + }, [doSearch]); + + // Keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusIdx(prev => Math.min(prev + 1, results.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusIdx(prev => Math.max(prev - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + if (results[focusIdx]) { + const node = results[focusIdx]; + onNavigate(node); + onClose(); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }, [results, focusIdx, onNavigate, onClose]); + + // Scroll focused item into view + useEffect(() => { + const el = listRef.current?.querySelector(`[data-search-idx="${focusIdx}"]`); + if (el) el.scrollIntoView({ block: 'nearest' }); + }, [focusIdx]); + + return ( + <div + className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" + style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} + > + <div + className="w-full max-w-lg bg-background border border-border rounded-xl shadow-2xl overflow-hidden" + onKeyDown={handleKeyDown} + > + {/* Search input */} + <div className="flex items-center gap-2 px-4 py-3 border-b border-border"> + <Search size={18} className="text-muted-foreground shrink-0" /> + <Input + ref={inputRef} + value={query} + onChange={(e) => handleInput(e.target.value)} + placeholder={translate("Search files and folders...")} + className="border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 text-base" + autoComplete="off" + spellCheck={false} + /> + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + updatePanel(activeSide, activePanelIdx, { searchFullText: !activePanel.searchFullText }); + }} + className={`shrink-0 flex items-center gap-1.5 px-2 py-1 rounded text-xs select-none transition-colors border ${activePanel.searchFullText ? 'bg-primary text-primary-foreground border-primary' : 'bg-transparent text-muted-foreground border-border hover:text-foreground'}`} + title={translate("Toggle Full Text Search")} + > + <CheckSquare size={14} className={activePanel.searchFullText ? 'opacity-100' : 'opacity-50'} /> + <span className="hidden sm:inline">FTS</span> + </button> + {loading && <Loader2 size={16} className="animate-spin text-muted-foreground shrink-0" />} + {query.trim().length > 0 && ( + <button + onClick={(e) => { + e.stopPropagation(); + updatePanel(activeSide, activePanelIdx, { searchQuery: query }); + onClose(); + }} + className="text-muted-foreground hover:text-foreground shrink-0 border border-border bg-muted/50 rounded p-1 ml-1" + title={translate("Open search in panel")} + > + <LayoutList size={14} /> + </button> + )} + {query.trim().length > 0 && ( + <button + onClick={(e) => { + e.stopPropagation(); + const cleanRoute = currentPath.startsWith('/') ? currentPath : `/${currentPath}`; + const url = `/app/filebrowser/${mount}${cleanRoute}?search=${encodeURIComponent(query)}`; + window.open(url, '_blank'); + onClose(); + }} + className="text-muted-foreground hover:text-foreground shrink-0 border border-border bg-muted/50 rounded p-1 ml-1" + title={translate("Open search in new tab")} + > + <ExternalLink size={14} /> + </button> + )} + <button onClick={onClose} className="text-muted-foreground hover:text-foreground shrink-0 ml-1"> + <X size={16} /> + </button> + </div> + + {/* Results */} + <div ref={listRef} className="max-h-[50vh] overflow-y-auto"> + {results.length === 0 && searched && !loading && ( + <div className="p-6 text-center text-muted-foreground text-sm"> + <T>No results found</T> + </div> + )} + {results.length === 0 && !searched && ( + <div className="p-6 text-center text-muted-foreground text-sm"> + <T>Type at least 2 characters to search</T> + </div> + )} + {results.map((node, i) => { + const isDir = getMimeCategory(node) === 'dir'; + const isFocused = focusIdx === i; + return ( + <div + key={`${node.path}-${i}`} + data-search-idx={i} + className={`flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors ${isFocused ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50' + }`} + onClick={() => { + onNavigate(node); + onClose(); + }} + onMouseEnter={() => setFocusIdx(i)} + > + <NodeIcon node={node} /> + <div className="flex-1 min-w-0"> + <div className="truncate text-sm font-medium">{node.name}</div> + <div className="truncate text-xs text-muted-foreground"> + {node.parent || '/'} + </div> + </div> + <ArrowRight size={14} className="opacity-30 shrink-0" /> + </div> + ); + })} + </div> + + {/* Footer */} + {results.length > 0 && ( + <div className="px-4 py-2 border-t border-border text-xs text-muted-foreground flex justify-between"> + <span>{results.length} <T>{results.length !== 1 ? 'results' : 'result'}</T>{truncated ? <T> (truncated)</T> : ''}</span> + <span className="opacity-60"><T>โ†‘โ†“ navigate ยท Enter open ยท Esc close</T></span> + </div> + )} + </div> + </div> + ); +}; + +export default SearchDialog; diff --git a/packages/ui/src/bundles/ecommerce.tsx b/packages/ui/src/bundles/ecommerce.tsx index 2af63469..f7b26f51 100644 --- a/packages/ui/src/bundles/ecommerce.tsx +++ b/packages/ui/src/bundles/ecommerce.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/hooks/useAuth"; -import { getCurrentLang } from "@/i18n"; +import { getCurrentLang, translate } from "@/i18n"; const EcommerceBundle = React.lazy(() => import("@polymech/ecommerce").then(m => ({ default: m.EcommerceBundle }))); @@ -71,6 +71,17 @@ export const EcommerceBundleWrapper = () => { apiBaseUrl: import.meta.env.VITE_SERVER_IMAGE_API_URL || "", getAuthToken: async () => session?.access_token ?? null, locale, + t: translate, + impressum: { + company: "Polymech S.L.", + representative: "Guenter Volkmar Baumgart", + address: "Poligono Can Clapers, Carrer Can Peric 11, 1B, 08181 Sentmenat, Barcelona, Spain", + email: "info@polymech.info", + phone: "+34 691 952 287", + vatId: "ES-Y0100830N", + register: "Registro Mercantil de Barcelona", + responsibleContent: "Guenter Volkmar Baumgart (ยง 18 Abs. 2 MStV)", + }, }; }, [user, session, navigate, locale]); diff --git a/packages/ui/src/components/CampaignsManager.tsx b/packages/ui/src/components/CampaignsManager.tsx new file mode 100644 index 00000000..2b9ed60a --- /dev/null +++ b/packages/ui/src/components/CampaignsManager.tsx @@ -0,0 +1,593 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { toast } from 'sonner'; +import { useSearchParams } from 'react-router-dom'; +import { + DataGrid, GridColDef, type GridFilterModel, type GridSortModel, + type GridColumnVisibilityModel, type GridPaginationModel +} from '@mui/x-data-grid'; +import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; +import { useMuiTheme } from '@/hooks/useMuiTheme'; +import { + filterModelToParams, paramsToFilterModel, + sortModelToParams, paramsToSortModel, + visibilityModelToParams, paramsToVisibilityModel, +} from '@/components/grids/gridUtils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter +} from '@/components/ui/dialog'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue +} from '@/components/ui/select'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle +} from '@/components/ui/alert-dialog'; +import { + Plus, Search, Trash2, Pencil, Check, FileText, Users, Send, ChevronDown, BarChart2 +} from 'lucide-react'; +import { T, translate } from '@/i18n'; +import { + Campaign, + fetchCampaigns, createCampaign, updateCampaign, deleteCampaign, sendCampaign, +} from '@/modules/campaigns/client-campaigns'; +import { fetchContactGroups, ContactGroup } from '@/modules/contacts/client-contacts'; +import { PagePickerDialog } from '@/modules/pages/PagePickerDialog'; +import { ContactsPicker } from '@/components/ContactsPicker'; +import { listMailboxes, type MailboxItem } from '@/modules/contacts/client-mailboxes'; + +// โ”€โ”€โ”€ Status badge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const STATUS_COLORS: Record<string, string> = { + draft: 'bg-slate-500/15 text-slate-600', + scheduled: 'bg-blue-500/15 text-blue-600', + sending: 'bg-yellow-500/15 text-yellow-600', + sent: 'bg-green-500/15 text-green-600', + failed: 'bg-red-500/15 text-red-600', +}; + +const STATUSES = ['draft', 'scheduled', 'sending', 'sent', 'failed'] as const; + +const StatusBadge = ({ status }: { status?: string }) => ( + <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[status || 'draft'] ?? STATUS_COLORS.draft}`}> + <T>{status || 'draft'}</T> + </span> +); + +// โ”€โ”€โ”€ Campaign Form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const EMPTY: Partial<Campaign> = { + name: '', page_slug: '', page_id: '', subject: '', + group_ids: [], lang: '', tracking_id: '', vars: {}, status: 'draft', +}; + +const CampaignForm = ({ + initial, onSave, onCancel, saving, groups, mailboxes +}: { + initial: Partial<Campaign>; + onSave: (c: Partial<Campaign>) => void; + onCancel: () => void; + saving: boolean; + groups: ContactGroup[]; + mailboxes: MailboxItem[]; +}) => { + const [c, setC] = useState<Partial<Campaign>>(initial); + const [pagePicker, setPagePicker] = useState(false); + + const set = (k: keyof Campaign, v: any) => setC(prev => ({ ...prev, [k]: v })); + + const groupNames = useMemo(() => { + const gMap = new Map(groups.map(g => [g.id, g.name])); + return (c.group_ids || []).map(id => gMap.get(id) || id); + }, [c.group_ids, groups]); + + return ( + <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1"> + <div className="space-y-1"> + <Label><T>Campaign name</T></Label> + <Input value={c.name || ''} onChange={e => set('name', e.target.value)} placeholder={translate('March Newsletter')} /> + </div> + + <div className="space-y-1"> + <Label><T>Subject</T></Label> + <Input value={c.subject || ''} onChange={e => set('subject', e.target.value)} placeholder={translate('Newsletter โ€” March 2026')} /> + </div> + + <div className="grid grid-cols-2 gap-3"> + {/* From Address */} + <div className="space-y-1 col-span-2"> + <Label><T>Send From</T></Label> + <Select + value={c.meta?.settings?.from_address || 'default'} + onValueChange={v => set('meta', { + ...(c.meta || {}), + settings: { + ...((c.meta as any)?.settings || {}), + from_address: v === 'default' ? null : v + } + })} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder={translate('System Default (config.json)')} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="default"><T>System Default (config.json)</T></SelectItem> + {mailboxes.map(mb => ( + <SelectItem key={mb.id} value={mb.user}> + {mb.user} {mb.label ? `(${mb.label})` : ''} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="space-y-1"> + <Label><T>Language</T></Label> + <Input value={c.lang || ''} onChange={e => set('lang', e.target.value)} placeholder="en, deโ€ฆ" /> + </div> + <div className="space-y-1"> + <Label><T>Tracking ID</T></Label> + <Input value={c.tracking_id || ''} onChange={e => set('tracking_id', e.target.value)} placeholder="mail-DD-HH-mm" /> + </div> + </div> + + {/* Page picker */} + <div className="space-y-1"> + <Label className="flex items-center gap-1"><FileText className="h-3 w-3" /><T>Email page</T></Label> + <div className="flex items-center gap-2"> + <Input readOnly value={c.page_slug || ''} placeholder={translate('Select a pageโ€ฆ')} className="flex-1 cursor-pointer" onClick={() => setPagePicker(true)} /> + <Button type="button" size="sm" variant="outline" onClick={() => setPagePicker(true)}> + <FileText className="h-3 w-3" /> + </Button> + </div> + <PagePickerDialog + isOpen={pagePicker} + onClose={() => setPagePicker(false)} + onSelect={(page) => { + if (page) { + set('page_slug', page.slug); + set('page_id', page.id); + } + }} + currentValue={c.page_id || null} + /> + </div> + + {/* Group picker */} + <div className="space-y-2"> + <Label className="flex items-center gap-1"><Users className="h-3 w-3" /><T>Target groups</T></Label> + <ContactsPicker + value={c.group_ids || []} + onChange={(ids) => set('group_ids', ids)} + /> + {groupNames.length > 0 && ( + <div className="flex flex-wrap gap-1"> + {groupNames.map((name, i) => ( + <Badge key={i} variant="secondary" className="text-xs">{name}</Badge> + ))} + </div> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={onCancel}><T>Cancel</T></Button> + <Button onClick={() => onSave(c)} disabled={saving || !c.name?.trim()}> + <Check className="h-3 w-3 mr-1" /><T>{saving ? 'Savingโ€ฆ' : 'Save'}</T> + </Button> + </DialogFooter> + </div> + ); +}; + +// โ”€โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const CampaignsManager = () => { + const muiTheme = useMuiTheme(); + const [searchParams, setSearchParams] = useSearchParams(); + + const q = searchParams.get('q') || ''; + const filterStatus = searchParams.get('status') || ''; + + const setFilter = useCallback((key: string, value: string) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const setQ = useCallback((v: string) => setFilter('q', v), [setFilter]); + const setFilterStatus = useCallback((v: string) => setFilter('status', v), [setFilter]); + + const [campaigns, setCampaigns] = useState<Campaign[]>([]); + const [groups, setGroups] = useState<ContactGroup[]>([]); + const [mailboxes, setMailboxes] = useState<MailboxItem[]>([]); + const [loading, setLoading] = useState(true); + + // Grid state from URL + const [filterModel, setFilterModel] = useState<GridFilterModel>(() => paramsToFilterModel(searchParams)); + const [sortModel, setSortModel] = useState<GridSortModel>(() => paramsToSortModel(searchParams)); + const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams)); + const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => ({ + page: parseInt(searchParams.get('page') || '0', 10), + pageSize: parseInt(searchParams.get('pageSize') || '25', 10), + })); + + // Dialogs + const [editCampaign, setEditCampaign] = useState<Partial<Campaign> | null>(null); + const [isNew, setIsNew] = useState(false); + const [saving, setSaving] = useState(false); + const [deleteId, setDeleteId] = useState<string | null>(null); + const [sendId, setSendId] = useState<string | null>(null); + const [sending, setSending] = useState(false); + const [sendInterval, setSendInterval] = useState(1); + + const load = async () => { + setLoading(true); + try { + const [c, g, mbs] = await Promise.all([ + fetchCampaigns({ q: q || undefined, status: filterStatus || undefined }), + fetchContactGroups(), + listMailboxes(), + ]); + setCampaigns(c); + setGroups(g); + setMailboxes(mbs); + } catch (e: any) { toast.error(e.message); } + finally { setLoading(false); } + }; + + useEffect(() => { load(); }, [q, filterStatus]); + + // Group name lookup + const groupById = useMemo(() => new Map(groups.map(g => [g.id, g])), [groups]); + + // โ”€โ”€ Columns โ”€โ”€ + const columns = useMemo<GridColDef[]>(() => [ + { + field: 'name', headerName: translate('Name'), flex: 1, minWidth: 200, + renderCell: (params: any) => ( + <div className="flex items-center gap-2 h-full min-w-0"> + <div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary/40 to-primary flex items-center justify-center text-white font-semibold text-xs shrink-0"> + <Send className="h-3.5 w-3.5" /> + </div> + <div className="min-w-0 flex flex-col justify-center leading-tight mt-0.5"> + <p className="font-medium text-sm truncate">{params.row.name || 'โ€”'}</p> + {params.row.subject && ( + <p className="text-[11px] text-muted-foreground truncate">{params.row.subject}</p> + )} + </div> + </div> + ) + }, + { + field: 'actions', headerName: translate('Actions'), width: 130, align: 'right', headerAlign: 'right', sortable: false, + renderCell: (params: any) => ( + <div className="flex items-center h-full justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity w-full pr-2"> + <Button size="icon" variant="ghost" className="h-7 w-7 text-primary hover:text-primary" + onClick={(e) => { e.stopPropagation(); setSendId(params.row.id); }}> + <Send className="h-3.5 w-3.5" /> + </Button> + <Button size="icon" variant="ghost" className="h-7 w-7 text-green-600 hover:text-green-700 hover:bg-green-50" + onClick={(e) => { + e.stopPropagation(); + // Open analytics pre-filtered by the tracking query + const url = `/admin/analytics?filter_search_contains=tracking%3D${params.row.tracking_id}`; + window.open(url, '_blank'); + }}> + <BarChart2 className="h-3.5 w-3.5" /> + </Button> + <Button size="icon" variant="ghost" className="h-7 w-7" + onClick={(e) => { e.stopPropagation(); setEditCampaign(params.row); setIsNew(false); }}> + <Pencil className="h-3.5 w-3.5" /> + </Button> + <Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" + onClick={(e) => { e.stopPropagation(); setDeleteId(params.row.id); }}> + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + ) + }, + { + field: 'page_slug', headerName: translate('Page'), width: 160, + renderCell: (params: any) => ( + <span className="text-xs text-muted-foreground truncate"> + {params.value ? `/${params.value}` : 'โ€”'} + </span> + ) + }, + { + field: 'status', headerName: translate('Status'), width: 120, + renderCell: (params: any) => ( + <div className="flex items-center h-full"> + <StatusBadge status={params.row.status} /> + </div> + ) + }, + { + field: 'group_ids', headerName: translate('Groups'), width: 180, + renderCell: (params: any) => { + const ids: string[] = params.row.group_ids || []; + return ( + <div className="flex items-center gap-1 flex-wrap h-full py-1"> + {ids.slice(0, 2).map(id => { + const g = groupById.get(id); + return <Badge key={id} variant="secondary" className="text-[10px] px-1.5 py-0 max-w-[66px] truncate">{g?.name || id.slice(0, 6)}</Badge>; + })} + {ids.length > 2 && ( + <Badge variant="secondary" className="text-[10px] px-1.5 py-0">+{ids.length - 2}</Badge> + )} + </div> + ); + } + }, + { + field: 'stats', headerName: translate('Stats'), width: 140, + renderCell: (params: any) => { + const s = params.row.stats || {}; + if (!s.total) return <span className="text-xs text-muted-foreground">โ€”</span>; + return ( + <div className="flex items-center gap-1.5 h-full text-xs tabular-nums"> + <span className="text-green-600">{s.sent ?? 0}โœ“</span> + {(s.failed ?? 0) > 0 && <span className="text-red-500">{s.failed}โœ•</span>} + {(s.skipped ?? 0) > 0 && <span className="text-muted-foreground">{s.skipped}โŠ˜</span>} + <span className="text-muted-foreground">/ {s.total}</span> + </div> + ); + } + }, + { + field: 'created_at', headerName: translate('Created'), width: 140, + renderCell: (params: any) => ( + <span className="text-xs text-muted-foreground"> + {params.value ? new Date(params.value).toLocaleDateString() : 'โ€”'} + </span> + ) + }, + ], [groupById]); + + // โ”€โ”€ CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const handleSave = async (data: Partial<Campaign>) => { + setSaving(true); + try { + if (isNew) { + const created = await createCampaign(data); + setCampaigns(prev => [created, ...prev]); + toast.success(translate('Campaign created')); + } else if (editCampaign?.id) { + const updated = await updateCampaign(editCampaign.id, data); + setCampaigns(prev => prev.map(x => x.id === updated.id ? updated : x)); + toast.success(translate('Campaign saved')); + } + setEditCampaign(null); + } catch (e: any) { toast.error(e.message); } + finally { setSaving(false); } + }; + + const handleDelete = async () => { + if (!deleteId) return; + try { + await deleteCampaign(deleteId); + setCampaigns(prev => prev.filter(c => c.id !== deleteId)); + toast.success(translate('Campaign deleted')); + } catch (e: any) { toast.error(e.message); } + finally { setDeleteId(null); } + }; + + const handleSend = async () => { + if (!sendId) return; + setSending(true); + try { + const stats = await sendCampaign(sendId, sendInterval); + toast.success(translate(`Sent: ${stats.sent}/${stats.total}${stats.failed ? `, ${stats.failed} failed` : ''}`)); + await load(); // Refresh to show updated status + stats + } catch (e: any) { toast.error(e.message); } + finally { setSending(false); setSendId(null); } + }; + + // โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + return ( + <div className="space-y-3"> + {/* โ”€โ”€ Toolbar โ”€โ”€ */} + <div className="flex flex-wrap items-center gap-2"> + <div className="relative flex-1 min-w-[180px]"> + <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> + <Input className="pl-8 h-8 text-sm" placeholder={translate('Searchโ€ฆ')} value={q} onChange={e => setQ(e.target.value)} /> + </div> + + <Select value={filterStatus || '__all'} onValueChange={v => setFilterStatus(v === '__all' ? '' : v)}> + <SelectTrigger className="h-8 w-36 text-sm"> + <ChevronDown className="h-3 w-3 mr-1 text-muted-foreground" /> + <SelectValue placeholder={translate('All statuses')} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__all"><T>All statuses</T></SelectItem> + {STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)} + </SelectContent> + </Select> + + <div className="flex items-center gap-1 ml-auto"> + <Button size="sm" className="h-8 text-xs gap-1" onClick={() => { setEditCampaign({ ...EMPTY }); setIsNew(true); }}> + <Plus className="h-3 w-3" /><T>New Campaign</T> + </Button> + </div> + </div> + + {/* โ”€โ”€ DataGrid โ”€โ”€ */} + {loading ? ( + <div className="py-12 text-center text-sm text-muted-foreground"><T>Loadingโ€ฆ</T></div> + ) : campaigns.length === 0 ? ( + <div className="py-12 text-center text-sm text-muted-foreground"> + <T>No campaigns found.</T>{' '} + <button className="underline" onClick={() => { setEditCampaign({ ...EMPTY }); setIsNew(true); }}> + <T>Create one</T> + </button> + </div> + ) : ( + <div className="h-[600px] w-full bg-background rounded-md border"> + <MuiThemeProvider theme={muiTheme}> + <DataGrid + rows={campaigns} + columns={columns} + getRowId={(row) => row.id} + showToolbar + disableRowSelectionOnClick + filterModel={filterModel} + onFilterModelChange={(newFilterModel) => { + setFilterModel(newFilterModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + Array.from(p.keys()).forEach(k => { if (k.startsWith('filter_')) p.delete(k); }); + Object.entries(filterModelToParams(newFilterModel)).forEach(([k, v]) => p.set(k, v)); + return p; + }, { replace: true }); + }} + sortModel={sortModel} + onSortModelChange={(newSortModel) => { + setSortModel(newSortModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + p.delete('sort'); + const sp = sortModelToParams(newSortModel); + if (sp.sort) p.set('sort', sp.sort); + return p; + }, { replace: true }); + }} + columnVisibilityModel={columnVisibilityModel} + onColumnVisibilityModelChange={(newModel) => { + setColumnVisibilityModel(newModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + p.delete('hidden'); + const vp = visibilityModelToParams(newModel); + if (vp.hidden !== undefined) p.set('hidden', vp.hidden); + return p; + }, { replace: true }); + }} + paginationModel={paginationModel} + onPaginationModelChange={(newPaginationModel) => { + setPaginationModel(newPaginationModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + if (newPaginationModel.page !== 0) p.set('page', String(newPaginationModel.page)); + else p.delete('page'); + if (newPaginationModel.pageSize !== 25) p.set('pageSize', String(newPaginationModel.pageSize)); + else p.delete('pageSize'); + return p; + }, { replace: true }); + }} + pageSizeOptions={[10, 25, 50]} + getRowClassName={() => 'group cursor-pointer hover:bg-muted/30 transition-colors'} + sx={{ + border: 0, + bgcolor: 'transparent', + color: 'hsl(var(--foreground))', + '& .MuiDataGrid-columnHeaders': { + borderColor: 'hsl(var(--border))', + }, + '& .MuiDataGrid-cell': { + borderColor: 'hsl(var(--border))', + '&:focus, &:focus-within': { outline: 'none' }, + }, + '& .MuiDataGrid-columnHeader': { + '&:focus, &:focus-within': { outline: 'none' }, + }, + '& .MuiDataGrid-footerContainer': { + borderColor: 'hsl(var(--border))', + }, + '& .MuiTablePagination-root': { + color: 'hsl(var(--muted-foreground))', + }, + '& .MuiDataGrid-overlay': { + bgcolor: 'transparent', + }, + '& .MuiCheckbox-root': { + color: 'hsl(var(--muted-foreground))', + '&.Mui-checked': { + color: 'hsl(var(--primary))', + }, + }, + '& .MuiDataGrid-columnSeparator': { + color: 'hsl(var(--border))', + }, + '& .MuiDataGrid-menuIcon button, & .MuiDataGrid-iconButtonContainer button': { + color: 'hsl(var(--muted-foreground))', + }, + }} + /> + </MuiThemeProvider> + </div> + )} + + <p className="text-xs text-muted-foreground text-right"> + {campaigns.length} {translate(campaigns.length !== 1 ? 'campaigns' : 'campaign')} + </p> + + {/* โ”€โ”€ Edit / Create dialog โ”€โ”€ */} + <Dialog open={!!editCampaign} onOpenChange={o => !o && setEditCampaign(null)}> + <DialogContent className="max-w-xl"> + <DialogHeader> + <DialogTitle><T>{isNew ? 'New Campaign' : 'Edit Campaign'}</T></DialogTitle> + </DialogHeader> + {editCampaign && ( + <CampaignForm + initial={editCampaign} + onSave={handleSave} + onCancel={() => setEditCampaign(null)} + saving={saving} + groups={groups} + mailboxes={mailboxes} + /> + )} + </DialogContent> + </Dialog> + + {/* โ”€โ”€ Delete confirm โ”€โ”€ */} + <AlertDialog open={!!deleteId} onOpenChange={o => !o && setDeleteId(null)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle><T>Delete campaign?</T></AlertDialogTitle> + <AlertDialogDescription><T>This cannot be undone.</T></AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel><T>Cancel</T></AlertDialogCancel> + <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + <T>Delete</T> + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* โ”€โ”€ Send confirm โ”€โ”€ */} + <AlertDialog open={!!sendId} onOpenChange={o => !o && !sending && setSendId(null)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle><T>Send campaign?</T></AlertDialogTitle> + <AlertDialogDescription><T>This will send the email to all contacts in the target groups. This cannot be undone.</T></AlertDialogDescription> + </AlertDialogHeader> + <div className="py-3"> + <Label className="text-sm"><T>Interval between sends (seconds)</T></Label> + <Input + type="number" min={0} step={0.5} + value={sendInterval} + onChange={e => setSendInterval(Math.max(0, parseFloat(e.target.value) || 0))} + className="mt-1 w-32 h-8 text-sm" + /> + </div> + <AlertDialogFooter> + <AlertDialogCancel disabled={sending}><T>Cancel</T></AlertDialogCancel> + <AlertDialogAction onClick={handleSend} disabled={sending} className="bg-primary text-primary-foreground hover:bg-primary/90"> + <Send className="h-3 w-3 mr-1" /><T>{sending ? 'Sendingโ€ฆ' : 'Send'}</T> + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ); +}; diff --git a/packages/ui/src/components/CategoryTreeView.tsx b/packages/ui/src/components/CategoryTreeView.tsx index 1b75f22a..8917479e 100644 --- a/packages/ui/src/components/CategoryTreeView.tsx +++ b/packages/ui/src/components/CategoryTreeView.tsx @@ -30,7 +30,9 @@ const filterByType = (cats: Category[], type: string): Category[] => }, []); const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => { - const { slug: activeSlug } = useParams<{ slug?: string }>(); + const params = useParams(); + const wildcardPath = params['*']; + const activeSlug = wildcardPath ? wildcardPath.split('/').filter(Boolean).pop() : undefined; const navigate = useNavigate(); // Track explicitly collapsed nodes (everything else is expanded by default) @@ -74,7 +76,8 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => onNavigate?.(); }, [navigate, onNavigate]); - const renderNode = (cat: Category, depth: number = 0) => { + const renderNode = (cat: Category, depth: number = 0, parentPath: string = '') => { + const fullPath = parentPath ? `${parentPath}/${cat.slug}` : cat.slug; const hasChildren = cat.children && cat.children.length > 0; const isActive = cat.slug === activeSlug; const isExpanded = hasChildren && !collapsedIds.has(cat.id); @@ -110,7 +113,7 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => {/* Category label โ€” clickable to navigate */} <button className="flex items-center gap-1.5 truncate text-left flex-1 min-w-0" - onClick={() => handleSelect(cat.slug)} + onClick={() => handleSelect(fullPath)} > <FolderTree className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" /> <span className="truncate">{cat.name}</span> @@ -119,7 +122,7 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => {hasChildren && ( <CollapsibleContent> - {cat.children!.map(rel => renderNode(rel.child, depth + 1))} + {cat.children!.map(rel => renderNode(rel.child, depth + 1, fullPath))} </CollapsibleContent> )} </Collapsible> diff --git a/packages/ui/src/components/ChatLogBrowser.tsx b/packages/ui/src/components/ChatLogBrowser.tsx new file mode 100644 index 00000000..a65c33a4 --- /dev/null +++ b/packages/ui/src/components/ChatLogBrowser.tsx @@ -0,0 +1,686 @@ +/** + * ChatLogBrowser โ€” Browse log entries with compact inline tree + * + * Transforms LogEntry[] into a navigable list. + * Drill into any entry to inspect its fields as a compact line-based tree. + * JSON-containing messages are auto-parsed into browsable sub-trees. + */ + +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { LogEntry } from '@/contexts/LogContext'; +import { resolveNavItems } from '@/components/json/JSONTreeWalker'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { + ListFilter, Trash2, Search, X, ChevronRight, ArrowLeft, Home, +} from 'lucide-react'; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ChatLogBrowserProps { + logs: LogEntry[]; + clearLogs: () => void; + title?: string; +} + +type LevelFilter = 'all' | 'info' | 'debug' | 'warning' | 'error' | 'success'; + +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const LEVEL_COLORS: Record<string, string> = { + error: 'text-red-500', + warning: 'text-yellow-500', + info: 'text-blue-500', + debug: 'text-purple-400', + success: 'text-green-500', +}; + +const LEVEL_BG: Record<string, string> = { + error: 'bg-red-500/10 border-red-500/20', + warning: 'bg-yellow-500/10 border-yellow-500/20', + info: 'bg-blue-500/10 border-blue-500/20', + debug: 'bg-purple-400/10 border-purple-400/20', + success: 'bg-green-500/10 border-green-500/20', +}; + +/** Try to extract JSON from a log message string. */ +function tryParseJson(msg: string): any | null { + try { + const parsed = JSON.parse(msg); + if (typeof parsed === 'object' && parsed !== null) return parsed; + } catch { /* not pure JSON */ } + const firstBrace = msg.search(/[{\[]/); + if (firstBrace >= 0) { + const sub = msg.slice(firstBrace); + try { + const parsed = JSON.parse(sub); + if (typeof parsed === 'object' && parsed !== null) return parsed; + } catch { /* no valid JSON suffix */ } + } + return null; +} + +/** Build a structured object from a LogEntry for tree browsing. */ +function logEntryToTree(log: LogEntry): Record<string, any> { + const tree: Record<string, any> = { + level: log.level, + timestamp: log.timestamp.toLocaleTimeString([], { + hour: '2-digit', minute: '2-digit', second: '2-digit', + }), + message: log.message, + }; + if (log.category) tree.category = log.category; + // Structured data from tools (preferred) or auto-parsed JSON from message + if (log.data) { + tree.data = log.data; + } else { + const parsed = tryParseJson(log.message); + if (parsed) tree.parsedContent = parsed; + } + return tree; +} + +/** Short preview of a log message. */ +function previewMessage(msg: string, maxLen = 80): string { + const line = msg.replace(/\n/g, ' ').trim(); + return line.length > maxLen ? line.slice(0, maxLen) + 'โ€ฆ' : line; +} + +/** Format a value for compact display. */ +function compactValue(val: any): string { + if (val === null) return 'null'; + if (val === undefined) return 'undefined'; + if (typeof val === 'object') { + if (Array.isArray(val)) return `[${val.length} items]`; + const keys = Object.keys(val); + return `{${keys.length} keys}`; + } + const s = String(val); + return s.length > 120 ? s.slice(0, 120) + 'โ€ฆ' : s; +} + +// โ”€โ”€ Compact Tree View โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface DeepSearchResult { + path: string[]; + key: string; + value: any; + displayValue: string; + canDrillIn: boolean; +} + +/** Recursively collect all matching key/value pairs with their full paths. */ +function deepCollect(obj: any, terms: string[], currentPath: string[], results: DeepSearchResult[], maxResults = 100): void { + if (results.length >= maxResults || obj == null || typeof obj !== 'object') return; + const entries = Array.isArray(obj) + ? obj.map((v, i) => [String(i), v] as [string, any]) + : Object.entries(obj); + for (const [key, value] of entries) { + const keyLower = key.toLowerCase(); + const isObj = value !== null && typeof value === 'object'; + const isArr = Array.isArray(value); + const valStr = isObj + ? (isArr ? `[${(value as any[]).length} items]` : `{${Object.keys(value as object).length} keys}`) + : String(value ?? 'null'); + const valLower = valStr.toLowerCase(); + const fullPath = [...currentPath, key]; + + // Check if this entry matches + const keyMatches = terms.every(t => keyLower.includes(t)); + const valMatches = !isObj && terms.every(t => valLower.includes(t)); + + if (keyMatches || valMatches) { + results.push({ + path: currentPath, + key, + value, + displayValue: valStr, + canDrillIn: isObj, + }); + } + + // Recurse into children regardless (to find deeper matches) + if (isObj && results.length < maxResults) { + deepCollect(value, terms, fullPath, results, maxResults); + } + } +} + +export const CompactTreeView: React.FC<{ + data: any; + onExit: () => void; + header: React.ReactNode; +}> = ({ data, onExit, header }) => { + const [path, setPath] = useState<string[]>([]); + const [selectedIdx, setSelectedIdx] = useState(0); + const [search, setSearch] = useState(''); + const containerRef = useRef<HTMLDivElement>(null); + const searchRef = useRef<HTMLInputElement>(null); + + // Resolve items at current path (normal browsing) + const rawItems = useMemo(() => { + return resolveNavItems(data, path) || []; + }, [data, path]); + + // Deep search results (flat list with paths) + const searchResults = useMemo(() => { + if (!search.trim()) return null; + const terms = search.toLowerCase().split(' ').filter(Boolean); + const results: DeepSearchResult[] = []; + deepCollect(data, terms, [], results); + return results; + }, [data, search]); + + // Active list: search results or normal items + const isSearchMode = searchResults !== null; + const listLength = isSearchMode ? searchResults!.length : rawItems.length; + + // Clamp selection + useEffect(() => { + if (selectedIdx >= listLength) setSelectedIdx(Math.max(0, listLength - 1)); + }, [listLength, selectedIdx]); + + // Auto-focus container on mount so keyboard nav works immediately + useEffect(() => { + requestAnimationFrame(() => containerRef.current?.focus()); + }, []); + + // Reset search on path change (selectedIdx is managed by goUp / drill-in) + useEffect(() => { + setSearch(''); + }, [path]); + + // Navigate to a deep search result + const navigateToResult = useCallback((result: DeepSearchResult) => { + // Navigate to the parent path, so the matched key is visible in the list + if (result.canDrillIn) { + setPath([...result.path, result.key]); + } else { + setPath(result.path); + } + setSelectedIdx(0); + }, []); + + // Keyboard nav + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) { + if (e.key === 'Escape') { + e.preventDefault(); + setSearch(''); + containerRef.current?.focus(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIdx(i => i >= listLength - 1 ? 0 : i + 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIdx(i => i <= 0 ? listLength - 1 : i - 1); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (isSearchMode && searchResults![selectedIdx]) { + navigateToResult(searchResults![selectedIdx]); + } + } + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIdx(i => i >= listLength - 1 ? 0 : i + 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIdx(i => i <= 0 ? listLength - 1 : i - 1); + } else if (e.key === 'ArrowRight' || e.key === 'Enter') { + e.preventDefault(); + if (isSearchMode) { + const r = searchResults![selectedIdx]; + if (r) navigateToResult(r); + } else { + const item = rawItems[selectedIdx]; + if (item?.canDrillIn) { + setPath(p => [...p, item.key]); + setSelectedIdx(0); + } + } + } else if (e.key === 'ArrowLeft' || e.key === 'Backspace') { + e.preventDefault(); + if (path.length > 0) { + const lastSeg = path[path.length - 1]; + const parentPath = path.slice(0, -1); + const parentItems = resolveNavItems(data, parentPath) || []; + const restoredIdx = parentItems.findIndex((it: any) => it.key === lastSeg); + setPath(parentPath); + setSelectedIdx(restoredIdx >= 0 ? restoredIdx : 0); + } else { + onExit(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + if (path.length > 0) { + const firstSeg = path[0]; + const rootItems = resolveNavItems(data, []) || []; + const restoredIdx = rootItems.findIndex((it: any) => it.key === firstSeg); + setPath([]); + setSelectedIdx(restoredIdx >= 0 ? restoredIdx : 0); + } + else onExit(); + } else if (e.key === 'Home') { + e.preventDefault(); + setSelectedIdx(0); + } else if (e.key === 'End') { + e.preventDefault(); + setSelectedIdx(listLength - 1); + } else if (e.key === '/' || (e.key === 'f' && e.ctrlKey)) { + e.preventDefault(); + searchRef.current?.focus(); + } + }; + el.addEventListener('keydown', handler); + return () => el.removeEventListener('keydown', handler); + }, [listLength, selectedIdx, path, onExit, isSearchMode, searchResults, rawItems, navigateToResult]); + + // Scroll selection into view + useEffect(() => { + const el = containerRef.current?.querySelector(`[data-tree-idx="${selectedIdx}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [selectedIdx]); + + return ( + <div className="flex flex-col h-full"> + {/* Header with back */} + {header} + + {/* Search bar */} + <div className="flex items-center gap-1 px-2 py-1 border-b bg-muted/10 flex-shrink-0"> + <Search className="h-2.5 w-2.5 text-muted-foreground" /> + <Input + ref={searchRef} + placeholder="Deep search... (/ to focus)" + className="h-5 text-[10px] bg-background flex-1 border-0 shadow-none focus-visible:ring-0 px-1 font-mono" + value={search} + onChange={e => { setSearch(e.target.value); setSelectedIdx(0); }} + /> + {search && ( + <button onClick={() => { setSearch(''); containerRef.current?.focus(); }} className="text-muted-foreground hover:text-foreground"> + <X className="h-2.5 w-2.5" /> + </button> + )} + {search && ( + <span className="text-[9px] text-muted-foreground/60 tabular-nums">{listLength}</span> + )} + </div> + + {/* Breadcrumb (only in browse mode) */} + {!isSearchMode && path.length > 0 && ( + <div className="flex items-center gap-0.5 px-2 py-1 border-b bg-muted/10 text-[10px] font-mono flex-shrink-0 overflow-x-auto"> + <button + onClick={() => { + const firstSeg = path[0]; + const rootItems = resolveNavItems(data, []) || []; + const idx = rootItems.findIndex((it: any) => it.key === firstSeg); + setPath([]); + setSelectedIdx(idx >= 0 ? idx : 0); + }} + className="flex items-center gap-0.5 px-1 py-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" + > + <Home className="h-2.5 w-2.5" /> + </button> + {path.map((seg, i) => ( + <React.Fragment key={i}> + <ChevronRight className="h-2.5 w-2.5 text-muted-foreground/40" /> + <button + onClick={() => { + const targetPath = path.slice(0, i + 1); + const childSeg = path[i + 1]; + if (childSeg) { + const items = resolveNavItems(data, targetPath) || []; + const idx = items.findIndex((it: any) => it.key === childSeg); + setSelectedIdx(idx >= 0 ? idx : 0); + } else { + setSelectedIdx(0); + } + setPath(targetPath); + }} + className={cn( + "px-1 py-0.5 rounded transition-colors", + i === path.length - 1 + ? "text-primary font-semibold" + : "text-muted-foreground hover:text-foreground hover:bg-muted" + )} + > + {seg} + </button> + </React.Fragment> + ))} + </div> + )} + + {/* Items / Search Results */} + <div + ref={containerRef} + tabIndex={0} + className="flex-1 overflow-y-auto outline-none font-mono text-[11px]" + > + {isSearchMode ? ( + /* โ”€โ”€ Deep search results โ”€โ”€ */ + searchResults!.length === 0 ? ( + <div className="text-muted-foreground text-xs text-center py-4 opacity-60">No matches</div> + ) : ( + searchResults!.map((result, idx) => ( + <div + key={`${result.path.join('.')}.${result.key}-${idx}`} + data-tree-idx={idx} + onClick={() => { setSelectedIdx(idx); navigateToResult(result); }} + className={cn( + "px-2 py-1 rounded cursor-pointer transition-colors group", + selectedIdx === idx + ? "bg-primary/10 ring-1 ring-primary/30" + : "hover:bg-muted/50", + )} + > + {/* Path breadcrumb */} + {result.path.length > 0 && ( + <div className="text-[9px] text-muted-foreground/50 truncate mb-0.5"> + {result.path.join(' โ€บ ')} + </div> + )} + {/* Key : Value */} + <div className="flex items-center gap-1.5"> + <span className="text-blue-400 flex-shrink-0">{result.key}</span> + <span className="text-muted-foreground/40">:</span> + <span className={cn( + "flex-1 min-w-0 truncate", + result.canDrillIn ? "text-muted-foreground" : "text-foreground/80" + )}> + {result.displayValue.length > 120 ? result.displayValue.slice(0, 120) + 'โ€ฆ' : result.displayValue} + </span> + {result.canDrillIn && ( + <ChevronRight className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-60 flex-shrink-0 transition-opacity" /> + )} + </div> + </div> + )) + ) + ) : ( + /* โ”€โ”€ Normal browse items โ”€โ”€ */ + rawItems.length === 0 ? ( + <div className="text-muted-foreground text-xs text-center py-4 opacity-60">Empty</div> + ) : ( + rawItems.map((item, idx) => ( + <div + key={item.key} + data-tree-idx={idx} + onClick={() => { + setSelectedIdx(idx); + if (item.canDrillIn) { + setPath(p => [...p, item.key]); + setSelectedIdx(0); + } + }} + className={cn( + "flex items-center gap-1.5 px-2 py-[3px] rounded cursor-pointer transition-colors group text-[11px] font-mono", + selectedIdx === idx + ? "bg-primary/10 ring-1 ring-primary/30" + : "hover:bg-muted/50", + )} + > + <span className="text-blue-400 flex-shrink-0">{item.key}</span> + <span className="text-muted-foreground/40">:</span> + <span className={cn( + "flex-1 min-w-0 truncate", + item.canDrillIn ? "text-muted-foreground" : "text-foreground/80" + )}> + {compactValue(item.value)} + </span> + {item.canDrillIn && ( + <ChevronRight className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-60 flex-shrink-0 transition-opacity" /> + )} + </div> + )) + ) + )} + </div> + </div> + ); +}; + +// โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const ChatLogBrowser: React.FC<ChatLogBrowserProps> = ({ logs, clearLogs, title = 'Chat Logs' }) => { + const [levelFilter, setLevelFilter] = useState<LevelFilter>('all'); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedIdx, setSelectedIdx] = useState<number | null>(null); + const [highlightedIdx, setHighlightedIdx] = useState<number>(0); + const listRef = useRef<HTMLDivElement>(null); + + // Filtered logs + const filtered = useMemo(() => { + let result = logs; + if (levelFilter !== 'all') { + result = result.filter(l => l.level === levelFilter); + } + if (searchTerm.trim()) { + const terms = searchTerm.toLowerCase().split(' ').filter(Boolean); + result = result.filter(l => + terms.every(t => + l.message.toLowerCase().includes(t) || + l.level.includes(t) || + (l.category || '').toLowerCase().includes(t) + ) + ); + } + return result; + }, [logs, levelFilter, searchTerm]); + + // Counts per level + const counts = useMemo(() => { + const c: Record<string, number> = { all: logs.length }; + for (const l of logs) c[l.level] = (c[l.level] || 0) + 1; + return c; + }, [logs]); + + // Reset highlight when filter changes + useEffect(() => { + setHighlightedIdx(0); + }, [levelFilter, searchTerm]); + + // Keyboard nav in list mode + useEffect(() => { + if (selectedIdx !== null) return; + const el = listRef.current; + if (!el) return; + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIdx(i => i >= filtered.length - 1 ? 0 : i + 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIdx(i => i <= 0 ? filtered.length - 1 : i - 1); + } else if (e.key === 'Enter' || e.key === 'ArrowRight') { + e.preventDefault(); + if (filtered.length > 0) setSelectedIdx(highlightedIdx); + } else if (e.key === 'Home') { + e.preventDefault(); + setHighlightedIdx(0); + } else if (e.key === 'End') { + e.preventDefault(); + setHighlightedIdx(filtered.length - 1); + } + }; + el.addEventListener('keydown', handler); + return () => el.removeEventListener('keydown', handler); + }, [selectedIdx, filtered, highlightedIdx]); + + // Scroll highlighted into view + useEffect(() => { + const el = listRef.current?.querySelector(`[data-log-idx="${highlightedIdx}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [highlightedIdx]); + + const handleBack = useCallback(() => { + setSelectedIdx(null); + requestAnimationFrame(() => listRef.current?.focus()); + }, []); + + // โ”€โ”€ Drilled-in view (compact inline tree) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if (selectedIdx !== null) { + const log = filtered[selectedIdx]; + if (!log) { setSelectedIdx(null); return null; } + const treeData = logEntryToTree(log); + return ( + <div className="border border-border rounded-lg bg-card flex flex-col h-full"> + <CompactTreeView + data={treeData} + onExit={handleBack} + header={ + <div className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-muted/20 flex-shrink-0"> + <Button + variant="ghost" + size="sm" + className="h-6 px-1.5 gap-1 text-xs" + onClick={handleBack} + > + <ArrowLeft className="h-3 w-3" /> + Back + </Button> + <span className={cn("text-[10px] font-semibold", LEVEL_COLORS[log.level])}> + [{log.level.toUpperCase()}] + </span> + <span className="text-[10px] text-muted-foreground truncate flex-1 min-w-0"> + {previewMessage(log.message, 40)} + </span> + </div> + } + /> + </div> + ); + } + + // โ”€โ”€ List view โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const TABS: { key: LevelFilter; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'info', label: 'Info' }, + { key: 'debug', label: 'Debug' }, + { key: 'warning', label: 'Warn' }, + { key: 'error', label: 'Err' }, + { key: 'success', label: 'OK' }, + ]; + + return ( + <div className="border border-border rounded-lg bg-card flex flex-col h-full"> + {/* Header */} + <div className="flex items-center justify-between p-2 px-3 border-b bg-muted/20 flex-shrink-0"> + <h3 className="text-sm font-bold flex items-center gap-1.5"> + <ListFilter className="h-3.5 w-3.5" /> + {title} + <span className="text-xs font-normal text-muted-foreground ml-1"> + ({filtered.length}) + </span> + </h3> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={clearLogs} + disabled={logs.length === 0} + title="Clear logs" + > + <Trash2 className="h-3 w-3" /> + </Button> + </div> + + {/* Search */} + <div className="flex items-center gap-1.5 px-2 py-1.5 border-b bg-muted/10 flex-shrink-0"> + <Search className="h-3 w-3 text-muted-foreground" /> + <Input + placeholder="Filter logs..." + className="h-6 text-xs bg-background flex-1 border-0 shadow-none focus-visible:ring-0 px-1" + value={searchTerm} + onChange={e => setSearchTerm(e.target.value)} + /> + {searchTerm && ( + <button onClick={() => setSearchTerm('')} className="text-muted-foreground hover:text-foreground"> + <X className="h-3 w-3" /> + </button> + )} + </div> + + {/* Level tabs */} + <div className="flex gap-0.5 px-2 py-1 border-b bg-muted/5 flex-shrink-0 overflow-x-auto"> + {TABS.map(tab => ( + <button + key={tab.key} + onClick={() => setLevelFilter(tab.key)} + className={cn( + "text-[10px] px-1.5 py-0.5 rounded transition-colors whitespace-nowrap", + levelFilter === tab.key + ? "bg-primary text-primary-foreground font-semibold" + : "text-muted-foreground hover:bg-muted hover:text-foreground" + )} + > + {tab.label} + {(counts[tab.key] || 0) > 0 && ( + <span className="ml-0.5 opacity-70">{counts[tab.key]}</span> + )} + </button> + ))} + </div> + + {/* Log list */} + <div + ref={listRef} + tabIndex={0} + className="flex-1 overflow-y-auto outline-none focus:ring-1 focus:ring-primary/20 focus:ring-inset" + > + {filtered.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-full text-muted-foreground text-xs py-8 opacity-60"> + <ListFilter className="h-6 w-6 mb-2 stroke-1" /> + <p>No logs{searchTerm ? ' matching filter' : ''}</p> + </div> + ) : ( + <div className="p-1"> + {filtered.map((log, idx) => ( + <div + key={log.id} + data-log-idx={idx} + onClick={() => { setHighlightedIdx(idx); setSelectedIdx(idx); }} + className={cn( + "flex items-start gap-1.5 px-2 py-1 rounded cursor-pointer transition-colors group text-[11px] font-mono", + highlightedIdx === idx + ? "bg-primary/10 ring-1 ring-primary/30" + : "hover:bg-muted/50", + )} + > + {/* Time */} + <span className="text-muted-foreground/70 flex-shrink-0 tabular-nums"> + {log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + </span> + {/* Level badge */} + <span className={cn( + "flex-shrink-0 px-1 rounded text-[10px] font-semibold border", + LEVEL_BG[log.level] || '', + LEVEL_COLORS[log.level] || 'text-muted-foreground' + )}> + {log.level.slice(0, 3).toUpperCase()} + </span> + {/* Message preview */} + <span className="flex-1 min-w-0 truncate text-foreground/90"> + {previewMessage(log.message)} + </span> + {/* Drill-in hint */} + <ChevronRight className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-60 flex-shrink-0 mt-0.5 transition-opacity" /> + </div> + ))} + </div> + )} + </div> + </div> + ); +}; + +export default ChatLogBrowser; diff --git a/packages/ui/src/components/CollapsibleSection.tsx b/packages/ui/src/components/CollapsibleSection.tsx index 9b208d66..366b8299 100644 --- a/packages/ui/src/components/CollapsibleSection.tsx +++ b/packages/ui/src/components/CollapsibleSection.tsx @@ -22,6 +22,7 @@ interface CollapsibleSectionProps { toggle: () => void, isOpen: boolean ) => React.ReactNode; + keepMounted?: boolean; // Keep component mounted when closed } const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ @@ -39,7 +40,8 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ onStateChange, // Destructure new prop id, minimal = false, - renderHeader + renderHeader, + keepMounted = false }) => { const [isOpen, setIsOpen] = useState(() => { if (storageKey) { @@ -78,23 +80,23 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ // Apply minimal styling if enabled const finalHeaderClassName = headerClassName || (minimal - ? 'flex justify-between items-center p-1 cursor-pointer border-b border-border' + ? 'flex justify-between items-center p-1 cursor-pointer border-t border-b border-border bg-muted/40' : 'flex justify-between items-center p-3 md:p-4 cursor-pointer border-b border-border' ); const finalTitleClassName = titleClassName || (minimal - ? 'text-sm font-semibold' - : 'text-md md:text-lg font-semibold' + ? 'pl-2 text-sm font-semibold' + : 'pl-2 text-md md:text-lg font-semibold' ); const finalContentClassName = contentClassName || (minimal - ? 'p-0' + ? 'p-2' : 'p-3 md:p-4' ); const finalContainerClassName = minimal - ? `bg-card ${className}` - : `rounded-lg shadow-none md:shadow-md border border-border bg-card ${className}`; + ? `dark:bg-slate-800/30 ${className} lg:rounded-lg` + : `rounded-lg shadow-none md:shadow-md border border-border ${className} lg:rounded-lg`; const header = renderHeader ? ( renderHeader(toggleOpen, isOpen) @@ -140,7 +142,14 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ </div> </div> </CardHeader> - {isOpen && <CardContent className={finalContentClassName}>{children}</CardContent>} + {(isOpen || keepMounted) && ( + <CardContent + className={finalContentClassName} + style={!isOpen ? { display: 'none' } : undefined} + > + {children} + </CardContent> + )} </Card> ); } @@ -148,7 +157,14 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({ return ( <div className={finalContainerClassName} id={id}> {header} - {isOpen && <div className={finalContentClassName}>{children}</div>} + {(isOpen || keepMounted) && ( + <div + className={finalContentClassName} + style={!isOpen ? { display: 'none' } : undefined} + > + {children} + </div> + )} </div> ); }; diff --git a/packages/ui/src/components/ContactsManager.tsx b/packages/ui/src/components/ContactsManager.tsx new file mode 100644 index 00000000..7fc7742d --- /dev/null +++ b/packages/ui/src/components/ContactsManager.tsx @@ -0,0 +1,1339 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { toast } from 'sonner'; +import { useSearchParams } from 'react-router-dom'; +import { + DataGrid, GridColDef, GridRowSelectionModel, GridRowId, + type GridFilterModel, type GridSortModel, type GridColumnVisibilityModel, type GridPaginationModel +} from '@mui/x-data-grid'; +import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; +import { useMuiTheme } from '@/hooks/useMuiTheme'; +import { + filterModelToParams, paramsToFilterModel, + sortModelToParams, paramsToSortModel, + visibilityModelToParams, paramsToVisibilityModel, +} from '@/components/grids/gridUtils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription +} from '@/components/ui/dialog'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue +} from '@/components/ui/select'; +import { + Popover, PopoverContent, PopoverTrigger, +} from '@/components/ui/popover'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle +} from '@/components/ui/alert-dialog'; +import { + Plus, Search, Download, Upload, Trash2, Pencil, Users, X, Check, + FolderOpen, ChevronDown, Mail, Phone, Building2, Globe, Tag, Layers, SlidersHorizontal, Code2 +} from 'lucide-react'; +import { T, translate } from '@/i18n'; +import { + Contact, ContactGroup, + fetchContacts, createContact, updateContact, deleteContact, batchDeleteContacts, + importContacts, exportContacts, + fetchContactGroups, createContactGroup, deleteContactGroup, + addGroupMembers, fetchGroupMembers, removeGroupMember, +} from '@/modules/contacts/client-contacts'; + +// โ”€โ”€โ”€ Status badge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const STATUS_COLORS: Record<string, string> = { + active: 'bg-green-500/15 text-green-600', + unsubscribed: 'bg-yellow-500/15 text-yellow-600', + bounced: 'bg-orange-500/15 text-orange-600', + blocked: 'bg-red-500/15 text-red-600', +}; + +const STATUSES = ['active', 'unsubscribed', 'bounced', 'blocked'] as const; + +const StatusBadge = ({ status }: { status?: string }) => ( + <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[status || 'active'] ?? STATUS_COLORS.active}`}> + <T>{status || 'active'}</T> + </span> +); + +// โ”€โ”€โ”€ Contact Form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const EMPTY: Partial<Contact> = { + name: '', first_name: '', last_name: '', + emails: [], phone: '', organization: '', title: '', + address: [], source: '', language: '', status: 'active', + notes: '', tags: [], meta: {}, +}; + +const ContactForm = ({ + initial, onSave, onCancel, saving, + groups, initialGroupIds, +}: { + initial: Partial<Contact>; + onSave: (c: Partial<Contact>, groupIds: string[]) => void; + onCancel: () => void; + saving: boolean; + groups: ContactGroup[]; + initialGroupIds: string[]; +}) => { + const [c, setC] = useState<Partial<Contact>>(initial); + const [emailInput, setEmailInput] = useState(''); + const [tagInput, setTagInput] = useState(''); + const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set(initialGroupIds)); + + const set = (k: keyof Contact, v: any) => setC(prev => ({ ...prev, [k]: v })); + + const toggleGroup = (id: string) => + setSelectedGroupIds(prev => { + const s = new Set(prev); + s.has(id) ? s.delete(id) : s.add(id); + return s; + }); + + const addEmail = () => { + if (!emailInput.trim()) return; + set('emails', [...(c.emails || []), { email: emailInput.trim(), label: 'INTERNET', primary: !(c.emails?.length) }]); + setEmailInput(''); + }; + const removeEmail = (i: number) => set('emails', (c.emails || []).filter((_, idx) => idx !== i)); + + const addTag = () => { + if (!tagInput.trim()) return; + set('tags', [...(c.tags || []), tagInput.trim()]); + setTagInput(''); + }; + const removeTag = (t: string) => set('tags', (c.tags || []).filter(x => x !== t)); + + return ( + <div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1"> + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-1"> + <Label><T>First name</T></Label> + <Input value={c.first_name || ''} onChange={e => set('first_name', e.target.value)} /> + </div> + <div className="space-y-1"> + <Label><T>Last name</T></Label> + <Input value={c.last_name || ''} onChange={e => set('last_name', e.target.value)} /> + </div> + </div> + + <div className="space-y-1"> + <Label><T>Display name</T></Label> + <Input value={c.name || ''} onChange={e => set('name', e.target.value)} /> + </div> + + <div className="space-y-2"> + <Label className="flex items-center gap-1"><Mail className="h-3 w-3" /><T>Emails</T></Label> + <div className="flex gap-2"> + <Input value={emailInput} onChange={e => setEmailInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addEmail())} + placeholder={translate('email@example.com')} /> + <Button type="button" size="sm" variant="outline" onClick={addEmail}><Plus className="h-3 w-3" /></Button> + </div> + <div className="flex flex-wrap gap-1"> + {(c.emails || []).map((em, i) => ( + <Badge key={i} variant="secondary" className="flex items-center gap-1"> + {em.email} + {em.primary && <span className="text-[10px] ml-0.5 text-muted-foreground">โ˜…</span>} + <button onClick={() => removeEmail(i)}><X className="h-2.5 w-2.5" /></button> + </Badge> + ))} + </div> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-1"> + <Label className="flex items-center gap-1"><Phone className="h-3 w-3" /><T>Phone</T></Label> + <Input value={c.phone || ''} onChange={e => set('phone', e.target.value)} /> + </div> + <div className="space-y-1"> + <Label className="flex items-center gap-1"><Building2 className="h-3 w-3" /><T>Organization</T></Label> + <Input value={c.organization || ''} onChange={e => set('organization', e.target.value)} /> + </div> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-1"> + <Label><T>Title</T></Label> + <Input value={c.title || ''} onChange={e => set('title', e.target.value)} /> + </div> + <div className="space-y-1"> + <Label className="flex items-center gap-1"><Globe className="h-3 w-3" /><T>Language</T></Label> + <Input value={c.language || ''} onChange={e => set('language', e.target.value)} placeholder={translate('en, deโ€ฆ')} /> + </div> + </div> + + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-1"> + <Label><T>Source</T></Label> + <Input value={c.source || ''} onChange={e => set('source', e.target.value)} placeholder={translate('import, manualโ€ฆ')} /> + </div> + <div className="space-y-1"> + <Label><T>Status</T></Label> + <Select value={c.status || 'active'} onValueChange={v => set('status', v as any)}> + <SelectTrigger><SelectValue /></SelectTrigger> + <SelectContent> + {STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)} + </SelectContent> + </Select> + </div> + </div> + + <div className="space-y-2"> + <Label className="flex items-center gap-1"><Tag className="h-3 w-3" /><T>Tags</T></Label> + <div className="flex gap-2"> + <Input value={tagInput} onChange={e => setTagInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addTag())} + placeholder={translate('Add tagโ€ฆ')} /> + <Button type="button" size="sm" variant="outline" onClick={addTag}><Plus className="h-3 w-3" /></Button> + </div> + <div className="flex flex-wrap gap-1"> + {(c.tags || []).map(t => ( + <Badge key={t} variant="outline" className="flex items-center gap-1"> + {t}<button onClick={() => removeTag(t)}><X className="h-2.5 w-2.5" /></button> + </Badge> + ))} + </div> + </div> + + <div className="space-y-2"> + <Label> + <T>Notes</T> + </Label> + <Textarea rows={2} value={c.notes || ''} onChange={e => set('notes', e.target.value)} /> + </div> + + {groups.length > 0 && ( + <div className="space-y-2"> + <Label className="flex items-center gap-1"><Users className="h-3 w-3" /><T>Groups</T></Label> + <div className="flex flex-wrap gap-1.5"> + {groups.map(g => { + const checked = selectedGroupIds.has(g.id); + return ( + <button + key={g.id} + type="button" + onClick={() => toggleGroup(g.id)} + className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${checked + ? 'bg-primary text-primary-foreground border-primary' + : 'bg-background text-muted-foreground border-border hover:border-primary/50' + }`} + > + {g.name} + </button> + ); + })} + </div> + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={onCancel}><T>Cancel</T></Button> + <Button onClick={() => { + // Auto-add pending email if user forgot to press + + let toSave = c; + if (emailInput.trim()) { + const updatedEmails = [...(c.emails || []), { email: emailInput.trim(), label: 'INTERNET', primary: !(c.emails?.length) }]; + toSave = { ...c, emails: updatedEmails }; + } + onSave(toSave, Array.from(selectedGroupIds)); + }} disabled={saving}> + <Check className="h-3 w-3 mr-1" /><T>{saving ? 'Savingโ€ฆ' : 'Save'}</T> + </Button> + </DialogFooter> + </div> + ); +}; + +const ActionBar = ({ + count, + total, + groups, + onSetGroup, + onRemoveGroups, + onSetStatus, + onDelete, + onClear, + onExport, + onNew, + busy, + resetKey, +}: { + count: number; + total: number; + groups: ContactGroup[]; + onSetGroup: (groupId: string) => void; + onRemoveGroups: () => void; + onSetStatus: (status: string) => void; + onDelete: () => void; + onClear: () => void; + onExport: (format: 'json' | 'vcard') => void; + onNew: () => void; + busy: boolean; + resetKey: number; +}) => { + const hasSelection = count > 0; + + return ( + <div className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 border"> + <span className="text-sm font-medium mr-2"> + {count > 0 ? ( + <span className="text-primary">{count} <T>selected</T></span> + ) : ( + <span className="text-muted-foreground"><T>Zero selected</T> ({total} <T>total</T>)</span> + )} + </span> + + {/* Set group */} + <Select key={`g-${resetKey}`} onValueChange={v => v === '__none' ? onRemoveGroups() : onSetGroup(v)} disabled={busy || total === 0}> + <SelectTrigger className="h-7 w-36 text-xs"> + <FolderOpen className="h-3 w-3 mr-1 text-muted-foreground" /> + <SelectValue placeholder={translate('Set groupโ€ฆ')} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none"><T>None</T></SelectItem> + {groups.map(g => <SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>)} + </SelectContent> + </Select> + + {/* Set status */} + <Select key={`s-${resetKey}`} onValueChange={onSetStatus} disabled={busy || total === 0}> + <SelectTrigger className="h-7 w-36 text-xs"> + <Layers className="h-3 w-3 mr-1 text-muted-foreground" /> + <SelectValue placeholder={translate('Set statusโ€ฆ')} /> + </SelectTrigger> + <SelectContent> + {STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)} + </SelectContent> + </Select> + + {/* Delete */} + {count > 0 ? ( + <Button size="sm" variant="destructive" className="h-7 text-xs gap-1" onClick={onDelete} disabled={busy}> + <Trash2 className="h-3 w-3" /><T>Delete ({count})</T> + </Button> + ) : ( + <Button size="sm" variant="destructive" className="h-7 text-xs gap-1" onClick={onDelete} disabled={busy || total === 0}> + <Trash2 className="h-3 w-3" /><T>Delete All ({total})</T> + </Button> + )} + + {hasSelection && ( + <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onClear} disabled={busy}> + <X className="h-3 w-3 mr-1" /><T>Clear</T> + </Button> + )} + + <div className="flex items-center gap-2 ml-auto"> + <Select onValueChange={f => onExport(f as any)}> + <SelectTrigger className="h-7 w-28 text-xs"> + <Download className="h-3 w-3 mr-1" /><T>Export</T> + </SelectTrigger> + <SelectContent> + <SelectItem value="json">JSON</SelectItem> + <SelectItem value="vcard">vCard (.vcf)</SelectItem> + </SelectContent> + </Select> + + <Button size="sm" className="h-7 text-xs gap-1" onClick={onNew}> + <Plus className="h-3 w-3" /><T>New</T> + </Button> + </div> + </div> + ); +}; + +// โ”€โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const ContactsManager = () => { + const muiTheme = useMuiTheme(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Filters from URL search params + const q = searchParams.get('q') || ''; + const filterGroup = searchParams.get('group') || ''; + const filterStatus = searchParams.get('status') || ''; + + const setFilter = useCallback((key: string, value: string) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const setQ = useCallback((v: string) => setFilter('q', v), [setFilter]); + const setFilterGroup = useCallback((v: string) => setFilter('group', v), [setFilter]); + const setFilterStatus = useCallback((v: string) => setFilter('status', v), [setFilter]); + + // Advanced fields + const [advancedFilters, setAdvancedFilters] = useState<Array<{ field: string, op: string, value: string }>>([]); + const advFields = [ + { id: 'name', label: 'Name' }, + { id: 'organization', label: 'Organization' }, + { id: 'email', label: 'Email' }, + { id: 'phone', label: 'Phone' }, + { id: 'website', label: 'Website' }, + { id: 'tags', label: 'Tag' }, + { id: 'city', label: 'City' }, + { id: 'country', label: 'Country' }, + { id: 'status', label: 'Status' }, + { id: 'group', label: 'Group' }, + { id: 'meta_all', label: 'Any Metadata' }, + { id: 'meta_key', label: 'Metadata (By Key)' } + ]; + const advOps = ['contains', 'does not contain', 'equals', 'does not equal', 'starts with', 'ends with', 'is empty', 'is not empty', 'is any of']; + + const [contacts, setContacts] = useState<Contact[]>([]); + const [groups, setGroups] = useState<ContactGroup[]>([]); + // contact_id โ†’ array of ContactGroup + const [contactGroupMap, setContactGroupMap] = useState<Map<string, ContactGroup[]>>(new Map()); + const [loading, setLoading] = useState(true); + + // Selection + const EMPTY_SELECTION: GridRowSelectionModel = { type: 'include', ids: new Set<GridRowId>() }; + const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>(EMPTY_SELECTION); + const [batchBusy, setBatchBusy] = useState(false); + const [batchResetKey, setBatchResetKey] = useState(0); + + // Grid state from URL + const [filterModel, setFilterModel] = useState<GridFilterModel>(() => paramsToFilterModel(searchParams)); + const [sortModel, setSortModel] = useState<GridSortModel>(() => paramsToSortModel(searchParams)); + const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams)); + const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => ({ + page: parseInt(searchParams.get('page') || '0', 10), + pageSize: parseInt(searchParams.get('pageSize') || '50', 10), + })); + + // Dialogs + const [editContact, setEditContact] = useState<Partial<Contact> | null>(null); + const [isNew, setIsNew] = useState(false); + const [saving, setSaving] = useState(false); + const [deleteId, setDeleteId] = useState<string | null>(null); + const [batchDelete, setBatchDelete] = useState(false); + + // Groups dialog + const [groupDialog, setGroupDialog] = useState(false); + const [newGroupName, setNewGroupName] = useState(''); + const [savingGroup, setSavingGroup] = useState(false); + + // Import + const importRef = useRef<HTMLInputElement>(null); + const [importing, setImporting] = useState(false); + + const load = async () => { + setLoading(true); + try { + const [c, g, members] = await Promise.all([ + fetchContacts({ q: q || undefined, group: filterGroup || undefined, status: filterStatus || undefined, limit: 10000 }), + fetchContactGroups(), + fetchGroupMembers(), + ]); + // Build contactโ†’groups map + const gMap = new Map<string, ContactGroup[]>(); + const groupById = new Map(g.map(grp => [grp.id, grp])); + for (const m of members) { + const grp = groupById.get(m.group_id); + if (!grp) continue; + const list = gMap.get(m.contact_id) || []; + list.push(grp); + gMap.set(m.contact_id, list); + } + setContactGroupMap(gMap); + setGroups(g); + + const enrichedContacts = c.map(contact => ({ + ...contact, + groups: gMap.get(contact.id) || [] + })); + setContacts(enrichedContacts); + + setRowSelectionModel({ type: 'include', ids: new Set<GridRowId>() }); + } catch (e: any) { toast.error(e.message); } + finally { setLoading(false); } + }; + + useEffect(() => { load(); }, [q, filterGroup, filterStatus]); + + // โ”€โ”€ Selection helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const filtered = contacts.filter(c => { + let match = true; + + // Main generic search bar + if (q) { + const needle = q.toLowerCase(); + match = !!( + c.name?.toLowerCase().includes(needle) || + c.organization?.toLowerCase().includes(needle) || + c.notes?.toLowerCase().includes(needle) || + c.phone?.toLowerCase().includes(needle) || + c.status?.toLowerCase().includes(needle) || + c.source?.toLowerCase().includes(needle) || + c.emails?.some(e => e.email.toLowerCase().includes(needle)) || + c.tags?.some(t => t.toLowerCase().includes(needle)) || + c.address?.some(a => + a.city?.toLowerCase().includes(needle) || + a.country?.toLowerCase().includes(needle) || + a.street?.toLowerCase().includes(needle) + ) || + (c.meta && Object.values(c.meta).some(val => + typeof val === 'string' && val.toLowerCase().includes(needle) + )) + ); + } + + if (!match) return false; + + // Advanced targeted fields (ALL must match) + if (advancedFilters.length > 0) { + for (const f of advancedFilters) { + const op = f.op || 'contains'; + const v = (f.value || '').toLowerCase(); + + // Skip value check for empty/not empty operators + if (!v && !['is empty', 'is not empty'].includes(op)) continue; + + let fieldVal = ''; + if (f.field === 'name') fieldVal = c.name || ''; + else if (f.field === 'organization') fieldVal = c.organization || ''; + else if (f.field === 'phone') fieldVal = c.phone || ''; + else if (f.field === 'email') fieldVal = c.emails?.map(e => e.email).join(', ') || ''; + else if (f.field === 'tags') fieldVal = c.tags?.join(', ') || ''; + else if (f.field === 'website') fieldVal = c.meta?.websites?.map((w: any) => w.url).join(', ') || ''; + else if (f.field === 'status') fieldVal = c.status || ''; + else if (f.field === 'group') fieldVal = (contactGroupMap.get(c.id) || []).map(g => g.name).join(', '); + else if (f.field === 'city' || f.field === 'country') { + fieldVal = c.address?.map(a => (a as any)[f.field]).filter(Boolean).join(', ') || ''; + } + else if (f.field === 'meta_all') { + fieldVal = c.meta ? Object.values(c.meta).map(v => typeof v === 'object' ? '' : String(v)).join(', ') : ''; + } + else if (f.field === 'meta_key') { + const mk = (f as any).metaKey?.trim(); + if (mk && c.meta) { + const val = c.meta[mk] ?? c.meta[mk.toLowerCase()] ?? c.meta[mk.charAt(0).toUpperCase() + mk.slice(1)]; + fieldVal = val != null && typeof val !== 'object' ? String(val) : ''; + } + } + + fieldVal = fieldVal.toLowerCase(); + + let ruleMatch = false; + switch (op) { + case 'contains': ruleMatch = fieldVal.includes(v); break; + case 'does not contain': ruleMatch = !fieldVal.includes(v); break; + case 'equals': ruleMatch = fieldVal === v; break; + case 'does not equal': ruleMatch = fieldVal !== v; break; + case 'starts with': ruleMatch = fieldVal.startsWith(v); break; + case 'ends with': ruleMatch = fieldVal.endsWith(v); break; + case 'is empty': ruleMatch = !fieldVal; break; + case 'is not empty': ruleMatch = !!fieldVal; break; + case 'is any of': + const parts = v.split(',').map(p => p.trim()).filter(Boolean); + ruleMatch = parts.some(p => fieldVal.includes(p)); + break; + default: ruleMatch = fieldVal.includes(v); + } + + if (!ruleMatch) { + match = false; + break; + } + } + } + + return match; + }); + + const isExclude = rowSelectionModel.type === 'exclude'; + const selectedCount = isExclude + ? filtered.length - rowSelectionModel.ids.size + : rowSelectionModel.ids.size; + + const selectedIds = isExclude + ? filtered.filter(c => !rowSelectionModel.ids.has(c.id)).map(c => c.id) + : ([...rowSelectionModel.ids] as string[]); + + // โ”€โ”€ Columns โ”€โ”€ + const columns = useMemo<GridColDef[]>(() => [ + { + field: 'name', headerName: translate('Name'), flex: 1.5, minWidth: 180, + renderCell: (params: any) => { + const contact = params.row; + return ( + <div className="flex items-center gap-2 h-full min-w-0"> + <div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary/40 to-primary flex items-center justify-center text-white font-semibold text-xs shrink-0"> + {(contact.name || contact.first_name || contact.emails?.[0]?.email || '?')[0].toUpperCase()} + </div> + <div className="min-w-0 flex flex-col justify-center leading-tight mt-0.5"> + <p className="font-medium text-sm truncate"> + {contact.name || `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || 'โ€”'} + </p> + {contact.organization && ( + <p className="text-[11px] text-muted-foreground truncate">{contact.organization}</p> + )} + </div> + </div> + ); + } + }, + { + field: 'actions', headerName: translate('Actions'), width: 110, align: 'right', headerAlign: 'right', sortable: false, + renderCell: (params: any) => ( + <div className="flex items-center h-full justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity w-full pr-2"> + <Popover> + <PopoverTrigger asChild> + <Button size="icon" variant="ghost" className="h-7 w-7" + onClick={(e) => e.stopPropagation()} title={translate('View Dump')}> + <Code2 className="h-3.5 w-3.5" /> + </Button> + </PopoverTrigger> + <PopoverContent align="end" className="w-[450px] max-h-[500px] overflow-auto p-4 z-50" onClick={e => e.stopPropagation()}> + <pre className="text-[10px] sm:text-xs text-muted-foreground whitespace-pre-wrap break-all font-mono"> + {JSON.stringify(params.row, null, 2)} + </pre> + </PopoverContent> + </Popover> + <Button size="icon" variant="ghost" className="h-7 w-7" + onClick={(e) => { e.stopPropagation(); setEditContact(params.row); setIsNew(false); }}> + <Pencil className="h-3.5 w-3.5" /> + </Button> + <Button size="icon" variant="ghost" className="h-7 w-7 text-destructive hover:text-destructive" + onClick={(e) => { e.stopPropagation(); setDeleteId(params.row.id); }}> + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </div> + ) + }, + { + field: 'email', headerName: translate('Email'), flex: 1, minWidth: 160, + valueGetter: (value: any, row: any) => row.emails?.[0]?.email, + renderCell: (params: any) => ( + <span className="text-xs text-muted-foreground truncate"> + {params.value || 'โ€”'} + </span> + ) + }, + { + field: 'status', headerName: translate('Status'), width: 100, + renderCell: (params: any) => ( + <div className="flex items-center h-full"> + <StatusBadge status={params.row.status} /> + </div> + ) + }, + { + field: 'groups', headerName: translate('Groups'), flex: 1, minWidth: 150, + valueGetter: (_value: any, row: any) => { + return (contactGroupMap.get(row.id) || []).map((g: any) => g.name).join(', ') + }, + renderCell: (params: any) => { + const groups = contactGroupMap.get(params.row.id) || []; + return ( + <div className="flex items-center gap-1 flex-wrap h-full py-1"> + {groups.slice(0, 2).map((g: any) => ( + <Badge key={g.id} variant="secondary" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium"> + <span className="truncate max-w-[60px]">{g.name}</span> + </Badge> + ))} + {groups.length > 2 && ( + <Badge variant="secondary" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium">+{groups.length - 2}</Badge> + )} + </div> + ); + } + }, + { + field: 'tags', headerName: translate('Tags'), flex: 1, minWidth: 120, + renderCell: (params: any) => { + const tags = params.row.tags || []; + return ( + <div className="flex items-center gap-1 flex-wrap h-full py-1"> + {tags.slice(0, 2).map((t: string) => ( + <Badge key={t} variant="outline" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium"> + <span className="truncate max-w-[60px]">{t}</span> + </Badge> + ))} + {tags.length > 2 && ( + <Badge variant="outline" className="text-[10px] h-5 min-h-[20px] px-1.5 py-0 font-medium">+{tags.length - 2}</Badge> + )} + </div> + ); + } + }, + { + field: 'notes', headerName: translate('Notes / Source'), flex: 1.5, minWidth: 150, + renderCell: (params: any) => ( + <div className="flex items-center h-full"> + <span className="text-xs text-muted-foreground truncate max-w-[200px]" title={params.row.notes || ''}> + {params.row.notes?.substring(0, 80) || 'โ€”'} + </span> + </div> + ) + }, + { + field: 'email_date', headerName: translate('Email Date'), width: 120, + valueGetter: (_value: any, row: any) => row.meta?.email?.date, + renderCell: (params: any) => { + if (!params.value) return <span className="text-xs text-muted-foreground">โ€”</span>; + const d = new Date(params.value); + return ( + <span className="text-xs text-muted-foreground" title={d.toLocaleString()}> + {d.toLocaleDateString()} + </span> + ); + } + }, + { + field: 'websites', headerName: translate('Websites'), flex: 1.2, minWidth: 160, + valueGetter: (_value: any, row: any) => row.meta?.websites, + renderCell: (params: any) => { + const sites: { url: string; source?: string }[] = params.value || []; + if (!sites.length) return <span className="text-xs text-muted-foreground">โ€”</span>; + return ( + <div className="flex items-center gap-1 flex-wrap h-full py-1"> + {sites.slice(0, 2).map((s, i) => { + let hostname = s.url; + try { hostname = new URL(s.url).hostname; } catch { } + return ( + <a + key={i} + href={s.url} + target="_blank" + rel="noreferrer" + onClick={e => e.stopPropagation()} + className="flex items-center gap-0.5 text-[10px] text-primary hover:underline bg-primary/8 px-1.5 py-0.5 rounded" + title={s.url} + > + <Globe className="h-2.5 w-2.5 shrink-0" /> + <span className="truncate max-w-[90px]">{hostname}</span> + </a> + ); + })} + {sites.length > 2 && ( + <Badge variant="secondary" className="text-[10px] h-5 px-1.5 py-0">+{sites.length - 2}</Badge> + )} + </div> + ); + } + } + ], [contactGroupMap]); + + + // โ”€โ”€ CRUD โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const handleSave = async (data: Partial<Contact>, newGroupIds: string[]) => { + setSaving(true); + try { + let savedId: string | undefined; + if (isNew) { + const created = await createContact(data); + setContacts(prev => [created, ...prev]); + savedId = created.id; + toast.success(translate('Contact created')); + } else if (editContact?.id) { + const updated = await updateContact(editContact.id, data); + setContacts(prev => prev.map(x => x.id === updated.id ? updated : x)); + savedId = updated.id; + toast.success(translate('Contact saved')); + } + // Sync group membership + if (savedId) { + const oldGroupIds = new Set((contactGroupMap.get(savedId) || []).map(g => g.id)); + const newSet = new Set(newGroupIds); + const toAdd = newGroupIds.filter(id => !oldGroupIds.has(id)); + const toRemove = [...oldGroupIds].filter(id => !newSet.has(id)); + await Promise.all([ + ...toAdd.map(gid => addGroupMembers(gid, [savedId!])), + ...toRemove.map(gid => removeGroupMember(gid, savedId!)), + ]); + if (toAdd.length || toRemove.length) load(); // refresh map + } + setEditContact(null); + } catch (e: any) { toast.error(e.message); } + finally { setSaving(false); } + }; + + const handleDelete = async () => { + if (!deleteId) return; + try { + await deleteContact(deleteId); + setContacts(prev => prev.filter(c => c.id !== deleteId)); + setRowSelectionModel((prev) => { const ids = new Set(prev.ids); ids.delete(deleteId); return { ...prev, ids }; }); + toast.success(translate('Contact deleted')); + } catch (e: any) { toast.error(e.message); } + finally { setDeleteId(null); } + }; + + // โ”€โ”€ Batch actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const getTargetIds = () => selectedIds.length > 0 ? selectedIds : filtered.map(c => c.id); + + const handleBatchSetGroup = async (groupId: string) => { + const ids = getTargetIds(); + if (!ids.length) return; + setBatchBusy(true); + try { + await addGroupMembers(groupId, ids); + const g = groups.find(x => x.id === groupId); + toast.success(`${ids.length} ${translate('contacts added to')} "${g?.name ?? groupId}"`); + load(); + } catch (e: any) { toast.error(e.message); } + finally { setBatchBusy(false); setBatchResetKey(k => k + 1); } + }; + + const handleBatchRemoveGroups = async () => { + const ids = getTargetIds(); + if (!ids.length) return; + setBatchBusy(true); + try { + // Remove every targeted contact from every group it belongs to + const removals: Promise<any>[] = []; + for (const cid of ids) { + const cGroups = contactGroupMap.get(cid) || []; + for (const g of cGroups) { + removals.push(removeGroupMember(g.id, cid)); + } + } + await Promise.all(removals); + toast.success(`${ids.length} ${translate('contacts removed from all groups')}`); + load(); + } catch (e: any) { toast.error(e.message); } + finally { setBatchBusy(false); setBatchResetKey(k => k + 1); } + }; + + const handleBatchSetStatus = async (status: string) => { + const ids = getTargetIds(); + if (!ids.length) return; + setBatchBusy(true); + try { + await Promise.all(ids.map(id => updateContact(id, { status: status as any }))); + const idsSet = new Set(ids); + setContacts(prev => prev.map(c => idsSet.has(c.id) ? { ...c, status: status as any } : c)); + toast.success(`${ids.length} ${translate('contacts updated')}`); + } catch (e: any) { toast.error(e.message); } + finally { setBatchBusy(false); setBatchResetKey(k => k + 1); } + }; + + const handleBatchDelete = async () => { + const isAll = selectedIds.length === 0; + const ids = getTargetIds(); + if (!ids.length) return; + + if (isAll) { + if (!window.confirm(translate(`Are you sure you want to delete ALL ${ids.length} contacts matching the current filter?`))) return; + } else { + if (!window.confirm(translate(`Delete ${ids.length} selected contacts?`))) return; + } + + setBatchBusy(true); + try { + await batchDeleteContacts(ids); + const idsSet = new Set(ids); + setContacts(prev => prev.filter(c => !idsSet.has(c.id))); + setRowSelectionModel({ type: 'include', ids: new Set<GridRowId>() }); + toast.success(`${ids.length} ${translate('contacts deleted')}`); + } catch (e: any) { toast.error(e.message); } + finally { setBatchBusy(false); setBatchDelete(false); } + }; + + // โ”€โ”€ Import / Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [importGroupId, setImportGroupId] = useState<string>('__none'); + + // Default group name = e.g. "group-24-12" + const getDefaultImportGroupName = () => { + const d = new Date(); + return `group-${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}`; + }; + const [importNewGroupName, setImportNewGroupName] = useState<string>(''); + + const handleOpenImportDialog = () => { + setImportGroupId('__none'); + setImportNewGroupName(getDefaultImportGroupName()); + setImportDialogOpen(true); + }; + + const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (!file) return; + setImporting(true); + setImportDialogOpen(false); // Optimistically close + + try { + const text = await file.text(); + const isVcard = file.name.endsWith('.vcf') || file.type === 'text/vcard'; + const body = isVcard ? text : JSON.parse(text); + + let targetGroupId: string | undefined = undefined; + + if (importGroupId === '__new') { + const groupName = importNewGroupName.trim() || getDefaultImportGroupName(); + const newGroup = await createContactGroup({ name: groupName }); + setGroups(prev => [...prev, newGroup]); // Append eagerly + targetGroupId = newGroup.id; + } else if (importGroupId !== '__none') { + targetGroupId = importGroupId; + } + + const result = await importContacts(body, isVcard ? 'vcard' : 'json', targetGroupId); + toast.success(translate(`Imported ${result.imported} contact(s), skipped ${result.skipped}`)); + if (targetGroupId) setFilterGroup(targetGroupId); + load(); + } catch (err: any) { + toast.error(`${translate('Import failed')}: ${err.message}`); + } finally { + setImporting(false); + if (importRef.current) importRef.current.value = ''; + } + }; + + const handleExport = async (format: 'json' | 'vcard') => { + try { + const result = await exportContacts({ + format, + group: filterGroup || undefined, + ids: selectedIds.length > 0 ? selectedIds : undefined + }); + const content = typeof result === 'string' ? result : JSON.stringify(result, null, 2); + const mime = format === 'vcard' ? 'text/vcard' : 'application/json'; + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `contacts.${format === 'vcard' ? 'vcf' : 'json'}`; a.click(); + URL.revokeObjectURL(url); + } catch (e: any) { toast.error(e.message); } + }; + + // โ”€โ”€ Groups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const handleCreateGroup = async () => { + if (!newGroupName.trim()) return; + setSavingGroup(true); + try { + const g = await createContactGroup({ name: newGroupName.trim() }); + setGroups(prev => [...prev, g]); + setNewGroupName(''); + setGroupDialog(false); + toast.success(translate('Group created')); + } catch (e: any) { toast.error(e.message); } + finally { setSavingGroup(false); } + }; + + const handleDeleteGroup = async (id: string) => { + try { + await deleteContactGroup(id); + setGroups(prev => prev.filter(g => g.id !== id)); + if (filterGroup === id) setFilterGroup(''); + toast.success(translate('Group deleted')); + } catch (e: any) { toast.error(e.message); } + }; + + // โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + return ( + <div className="space-y-3"> + {/* โ”€โ”€ Toolbar โ”€โ”€ */} + <div className="flex flex-wrap items-center gap-2"> + <div className="relative flex-1 min-w-[180px] flex items-center"> + <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10" /> + <Input className="pl-8 h-8 text-sm" placeholder={translate('Searchโ€ฆ')} value={q} onChange={e => setQ(e.target.value)} /> + + <Popover> + <PopoverTrigger asChild> + <Button variant="ghost" size="icon" className={`absolute right-1 h-6 w-6 rounded-sm ${advancedFilters.length ? 'bg-primary/10 text-primary' : 'text-muted-foreground'}`}> + <SlidersHorizontal className="h-3.5 w-3.5" /> + </Button> + </PopoverTrigger> + <PopoverContent align="start" className="w-max min-w-[320px] max-w-[95vw] p-3 shadow-xl"> + <div className="space-y-3"> + <div> + <h4 className="text-sm font-semibold mb-1"><T>Advanced Search</T></h4> + <p className="text-xs text-muted-foreground"><T>Requires all specific rules to match.</T></p> + </div> + <div className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-hidden px-1 py-1 -mx-1 -my-1"> + {advancedFilters.map((f, i) => ( + <div key={i} className="flex gap-1 items-center animate-in fade-in slide-in-from-top-1"> + <Select value={f.field} onValueChange={v => { + const neu = [...advancedFilters]; + neu[i].field = v; + setAdvancedFilters(neu); + }}> + <SelectTrigger className="h-7 w-[90px] text-xs px-2 shrink-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {advFields.map(af => <SelectItem key={advFields.indexOf(af) + af.id} value={af.id}>{translate(af.label)}</SelectItem>)} + </SelectContent> + </Select> + {f.field === 'meta_key' && ( + <Input className="h-7 text-xs w-[100px] shrink-0" value={(f as any).metaKey || ''} placeholder={translate('Key')} + onChange={e => { + const neu = [...advancedFilters] as any[]; + neu[i].metaKey = e.target.value; + setAdvancedFilters(neu); + }} /> + )} + <Select value={f.op} onValueChange={v => { + const neu = [...advancedFilters]; + neu[i].op = v; + setAdvancedFilters(neu); + }}> + <SelectTrigger className="h-7 w-[110px] text-xs px-2 shrink-0"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {advOps.map(op => <SelectItem key={op} value={op}><T>{op}</T></SelectItem>)} + </SelectContent> + </Select> + {!['is empty', 'is not empty'].includes(f.op) && ( + <Input className="h-7 text-xs flex-1 min-w-[60px]" value={f.value} placeholder={translate('value')} + onChange={e => { + const neu = [...advancedFilters]; + neu[i].value = e.target.value; + setAdvancedFilters(neu); + }} /> + )} + <Button variant="ghost" size="icon" className="h-6 w-6 text-destructive shrink-0" + onClick={() => setAdvancedFilters(advancedFilters.filter((_, idx) => idx !== i))}> + <X className="h-3.5 w-3.5" /> + </Button> + </div> + ))} + {advancedFilters.length === 0 && ( + <div className="py-4 text-center text-xs text-muted-foreground border rounded bg-muted/30"> + <T>No specific field rules yet.</T> + </div> + )} + </div> + <div className="flex items-center gap-2 pt-1 border-t"> + <Button size="sm" variant="outline" className="h-7 text-xs flex-1" + onClick={() => setAdvancedFilters([...advancedFilters, { field: 'name', op: 'contains', value: '' }])}> + <Plus className="h-3 w-3 mr-1" /><T>Add Rule</T> + </Button> + {advancedFilters.length > 0 && ( + <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAdvancedFilters([])}> + <T>Clear</T> + </Button> + )} + </div> + </div> + </PopoverContent> + </Popover> + </div> + + <Select value={filterGroup || '__all'} onValueChange={v => setFilterGroup(v === '__all' ? '' : v)}> + <SelectTrigger className="h-8 w-36 text-sm"> + <FolderOpen className="h-3 w-3 mr-1 text-muted-foreground" /> + <SelectValue placeholder={translate('All groups')} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__all"><T>All groups</T></SelectItem> + {groups.map(g => <SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>)} + </SelectContent> + </Select> + + <Select value={filterStatus || '__all'} onValueChange={v => setFilterStatus(v === '__all' ? '' : v)}> + <SelectTrigger className="h-8 w-36 text-sm"> + <ChevronDown className="h-3 w-3 mr-1 text-muted-foreground" /> + <SelectValue placeholder={translate('All statuses')} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__all"><T>All statuses</T></SelectItem> + {STATUSES.map(s => <SelectItem key={s} value={s}>{translate(s)}</SelectItem>)} + </SelectContent> + </Select> + + <div className="flex items-center gap-1 ml-auto"> + <Button size="sm" variant="outline" className="h-8 text-xs gap-1" onClick={handleOpenImportDialog} disabled={importing}> + <Upload className="h-3 w-3" /><T>{importing ? 'Importingโ€ฆ' : 'Import'}</T> + </Button> + + <Button size="sm" variant="outline" className="h-8 text-xs gap-1" onClick={() => setGroupDialog(true)}> + <Users className="h-3 w-3" /><T>Groups</T> + </Button> + </div> + </div> + + <ActionBar + count={selectedCount} + total={filtered.length} + groups={groups} + onSetGroup={handleBatchSetGroup} + onRemoveGroups={handleBatchRemoveGroups} + onSetStatus={handleBatchSetStatus} + onDelete={() => setBatchDelete(true)} + onClear={() => setRowSelectionModel({ type: 'include', ids: new Set<GridRowId>() })} + onExport={handleExport} + onNew={() => { setEditContact({ ...EMPTY }); setIsNew(true); }} + busy={batchBusy} + resetKey={batchResetKey} + /> + + <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle><T>Import Contacts</T></DialogTitle> + <DialogDescription className="text-sm text-muted-foreground pt-1.5"> + <T>Select a JSON or vCard file. Optionally, drop them directly into a group.</T> + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-2"> + <div className="space-y-2"> + <Label><T>Destination Group</T></Label> + <Select value={importGroupId} onValueChange={setImportGroupId}> + <SelectTrigger> + <SelectValue placeholder={translate('None')} /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none"><T>{"None (Don\'t Group)"}</T></SelectItem> + <SelectItem value="__new"><T>+ Create New Group</T></SelectItem> + {groups.map(g => ( + <SelectItem key={g.id} value={g.id}>{g.name}</SelectItem> + ))} + </SelectContent> + </Select> + </div> + {importGroupId === '__new' && ( + <div className="space-y-2 animate-in fade-in slide-in-from-top-2"> + <Label><T>New Group Name</T></Label> + <Input + value={importNewGroupName} + onChange={e => setImportNewGroupName(e.target.value)} + placeholder={translate('group-dd-mm')} + /> + </div> + )} + </div> + + <DialogFooter> + <input ref={importRef} type="file" accept=".json,.vcf" className="hidden" onChange={handleImportFile} /> + <Button variant="outline" onClick={() => setImportDialogOpen(false)}><T>Cancel</T></Button> + <Button onClick={() => importRef.current?.click()}> + <T>Select File & Import</T> + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + + {/* โ”€โ”€ Contact list โ”€โ”€ */} + {loading ? ( + <div className="py-12 text-center text-sm text-muted-foreground"><T>Loadingโ€ฆ</T></div> + ) : filtered.length === 0 ? ( + <div className="py-12 text-center text-sm text-muted-foreground"> + <T>No contacts found.</T>{' '} + <button className="underline" onClick={() => { setEditContact({ ...EMPTY }); setIsNew(true); }}> + <T>Add one</T> + </button> + </div> + ) : ( + <div className="h-[600px] w-full bg-background rounded-md border flex flex-col"> + <MuiThemeProvider theme={muiTheme}> + <DataGrid + rows={filtered} + columns={columns} + checkboxSelection + showToolbar + disableRowSelectionOnClick + disableColumnMenu + rowSelectionModel={rowSelectionModel} + onRowSelectionModelChange={(newSelection) => { + setRowSelectionModel(newSelection); + }} + filterModel={filterModel} + onFilterModelChange={(newFilterModel) => { + setFilterModel(newFilterModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + Array.from(p.keys()).forEach(k => { if (k.startsWith('filter_')) p.delete(k); }); + Object.entries(filterModelToParams(newFilterModel)).forEach(([k, v]) => p.set(k, v)); + return p; + }, { replace: true }); + }} + sortModel={sortModel} + onSortModelChange={(newSortModel) => { + setSortModel(newSortModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + p.delete('sort'); + const sp = sortModelToParams(newSortModel); + if (sp.sort) p.set('sort', sp.sort); + return p; + }, { replace: true }); + }} + columnVisibilityModel={columnVisibilityModel} + onColumnVisibilityModelChange={(newModel) => { + setColumnVisibilityModel(newModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + p.delete('hidden'); + const vp = visibilityModelToParams(newModel); + if (vp.hidden !== undefined) p.set('hidden', vp.hidden); + return p; + }, { replace: true }); + }} + paginationModel={paginationModel} + onPaginationModelChange={(newPaginationModel) => { + setPaginationModel(newPaginationModel); + setSearchParams(prev => { + const p = new URLSearchParams(prev); + if (newPaginationModel.page !== 0) p.set('page', String(newPaginationModel.page)); + else p.delete('page'); + if (newPaginationModel.pageSize !== 50) p.set('pageSize', String(newPaginationModel.pageSize)); + else p.delete('pageSize'); + return p; + }, { replace: true }); + }} + pageSizeOptions={[20, 50, 100]} + getRowClassName={() => 'group cursor-pointer hover:bg-muted/30 transition-colors'} + sx={{ + border: 0, + bgcolor: 'transparent', + color: 'hsl(var(--foreground))', + '& .MuiDataGrid-columnHeaders': { + borderColor: 'hsl(var(--border))', + }, + '& .MuiDataGrid-cell': { + borderColor: 'hsl(var(--border))', + '&:focus, &:focus-within': { outline: 'none' }, + }, + '& .MuiDataGrid-columnHeader': { + '&:focus, &:focus-within': { outline: 'none' }, + }, + '& .MuiDataGrid-row.Mui-selected': { + backgroundColor: 'hsl(var(--primary) / 0.05)', + '&:hover': { + backgroundColor: 'hsl(var(--primary) / 0.1)', + }, + }, + '& .MuiDataGrid-footerContainer': { + borderColor: 'hsl(var(--border))', + }, + '& .MuiTablePagination-root': { + color: 'hsl(var(--muted-foreground))', + }, + '& .MuiDataGrid-overlay': { + bgcolor: 'transparent', + }, + '& .MuiCheckbox-root': { + color: 'hsl(var(--muted-foreground))', + '&.Mui-checked': { + color: 'hsl(var(--primary))', + }, + }, + '& .MuiDataGrid-columnSeparator': { + color: 'hsl(var(--border))', + }, + '& .MuiDataGrid-menuIcon button, & .MuiDataGrid-iconButtonContainer button': { + color: 'hsl(var(--muted-foreground))', + }, + '& .MuiDataGrid-virtualScroller': { + overflowX: 'auto', + }, + }} + /> + </MuiThemeProvider> + </div> + )} + + <p className="text-xs text-muted-foreground text-right"> + {filtered.length} {translate(filtered.length !== 1 ? 'contacts' : 'contact')} + </p> + + {/* โ”€โ”€ Edit / Create dialog โ”€โ”€ */} + <Dialog open={!!editContact} onOpenChange={o => !o && setEditContact(null)}> + <DialogContent className="max-w-xl"> + <DialogHeader> + <DialogTitle><T>{isNew ? 'New Contact' : 'Edit Contact'}</T></DialogTitle> + </DialogHeader> + {editContact && ( + <ContactForm + initial={editContact} + onSave={handleSave} + onCancel={() => setEditContact(null)} + saving={saving} + groups={groups} + initialGroupIds={(contactGroupMap.get(editContact.id ?? '') || []).map(g => g.id)} + /> + )} + </DialogContent> + </Dialog> + + {/* โ”€โ”€ Single delete confirm โ”€โ”€ */} + <AlertDialog open={!!deleteId} onOpenChange={o => !o && setDeleteId(null)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle><T>Delete contact?</T></AlertDialogTitle> + <AlertDialogDescription><T>This cannot be undone.</T></AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel><T>Cancel</T></AlertDialogCancel> + <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + <T>Delete</T> + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* โ”€โ”€ Batch delete confirm โ”€โ”€ */} + <AlertDialog open={batchDelete} onOpenChange={o => !o && setBatchDelete(false)}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + <T>Delete</T> {selectedIds.length} <T>contacts?</T> + </AlertDialogTitle> + <AlertDialogDescription><T>This cannot be undone.</T></AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={batchBusy}><T>Cancel</T></AlertDialogCancel> + <AlertDialogAction onClick={handleBatchDelete} disabled={batchBusy} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + <T>{batchBusy ? 'Deletingโ€ฆ' : 'Delete all'}</T> + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* โ”€โ”€ Groups manager dialog โ”€โ”€ */} + <Dialog open={groupDialog} onOpenChange={setGroupDialog}> + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle><T>Contact Groups</T></DialogTitle> + </DialogHeader> + <div className="space-y-3"> + <div className="flex gap-2"> + <Input placeholder={translate('New group nameโ€ฆ')} value={newGroupName} + onChange={e => setNewGroupName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateGroup()} /> + <Button size="sm" onClick={handleCreateGroup} disabled={savingGroup || !newGroupName.trim()}> + <Plus className="h-3 w-3" /> + </Button> + </div> + <div className="divide-y rounded-lg border"> + {groups.length === 0 && ( + <p className="text-xs text-muted-foreground text-center py-4"><T>No groups yet.</T></p> + )} + {groups.map(g => ( + <div key={g.id} className="flex items-center justify-between px-3 py-2 text-sm group"> + <span>{g.name}</span> + <Button size="icon" variant="ghost" className="h-6 w-6 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100" + onClick={() => handleDeleteGroup(g.id)}> + <Trash2 className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + </DialogContent> + </Dialog> + </div> + ); +}; diff --git a/packages/ui/src/components/ContactsPicker.tsx b/packages/ui/src/components/ContactsPicker.tsx new file mode 100644 index 00000000..be035d5b --- /dev/null +++ b/packages/ui/src/components/ContactsPicker.tsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter +} from '@/components/ui/dialog'; +import { Search, Users, FolderOpen, Check, Loader2 } from 'lucide-react'; +import { T, translate } from '@/i18n'; +import { + ContactGroup, + fetchContactGroups, fetchGroupMembers, +} from '@/modules/contacts/client-contacts'; + +// โ”€โ”€โ”€ Props โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface ContactsPickerProps { + /** Currently selected group IDs (controlled). */ + value?: string[]; + /** Called whenever the selection changes. */ + onChange?: (groupIds: string[]) => void; + /** If provided, wraps the picker in a Dialog triggered by `trigger`. */ + trigger?: React.ReactNode; + /** Called when the dialog confirms selection. Only used in dialog mode. */ + onConfirm?: (groupIds: string[]) => void; + /** Disable all interactions. */ + disabled?: boolean; + /** Placeholder shown when nothing is selected. */ + placeholder?: string; +} + +// โ”€โ”€โ”€ Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const ContactsPicker = ({ + value, + onChange, + trigger, + onConfirm, + disabled, + placeholder, +}: ContactsPickerProps) => { + const [groups, setGroups] = useState<ContactGroup[]>([]); + const [memberCounts, setMemberCounts] = useState<Map<string, number>>(new Map()); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + // Dialog mode state + const [open, setOpen] = useState(false); + const [pendingIds, setPendingIds] = useState<Set<string>>(new Set(value ?? [])); + + const selectedIds = new Set(value ?? []); + const isDialog = !!trigger; + + const load = useCallback(async () => { + setLoading(true); + try { + const [g, members] = await Promise.all([ + fetchContactGroups(), + fetchGroupMembers(), + ]); + setGroups(g); + // Count contacts per group + const counts = new Map<string, number>(); + for (const m of members) { + counts.set(m.group_id, (counts.get(m.group_id) || 0) + 1); + } + setMemberCounts(counts); + } catch (e: any) { + toast.error(e.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + // Sync pendingIds when value changes externally (dialog mode) + useEffect(() => { setPendingIds(new Set(value ?? [])); }, [value]); + + const toggle = (id: string) => { + if (disabled) return; + if (isDialog) { + setPendingIds(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } else { + const next = new Set(selectedIds); + next.has(id) ? next.delete(id) : next.add(id); + onChange?.(Array.from(next)); + } + }; + + const handleConfirm = () => { + const ids = Array.from(pendingIds); + onChange?.(ids); + onConfirm?.(ids); + setOpen(false); + }; + + const filtered = groups.filter(g => { + if (!search) return true; + return g.name.toLowerCase().includes(search.toLowerCase()); + }); + + const activeSet = isDialog ? pendingIds : selectedIds; + + // โ”€โ”€โ”€ Render list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const listContent = ( + <div className="space-y-2"> + {groups.length > 5 && ( + <div className="relative"> + <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> + <Input + className="pl-8 h-8 text-sm" + placeholder={translate('Search groupsโ€ฆ')} + value={search} + onChange={e => setSearch(e.target.value)} + /> + </div> + )} + + {loading ? ( + <div className="flex items-center justify-center py-6"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + </div> + ) : filtered.length === 0 ? ( + <p className="text-xs text-muted-foreground text-center py-4"> + <T>{groups.length === 0 ? 'No groups yet.' : 'No matching groups.'}</T> + </p> + ) : ( + <div className="divide-y rounded-lg border max-h-[300px] overflow-y-auto"> + {filtered.map(g => { + const checked = activeSet.has(g.id); + const count = memberCounts.get(g.id) || 0; + return ( + <div + key={g.id} + role="button" + tabIndex={0} + onClick={() => !disabled && toggle(g.id)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(g.id); } }} + className={`flex items-center w-full gap-3 px-3 py-2.5 text-sm text-left transition-colors + ${checked ? 'bg-primary/5' : 'hover:bg-muted/50'} + ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} + > + <Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" /> + <FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> + <span className="flex-1 truncate">{g.name}</span> + <Badge variant="secondary" className="text-[10px] px-1.5 py-0 tabular-nums"> + {count} + </Badge> + </div> + ); + })} + </div> + )} + + {activeSet.size > 0 && ( + <p className="text-xs text-muted-foreground text-right"> + {activeSet.size} {translate(activeSet.size !== 1 ? 'groups selected' : 'group selected')} + </p> + )} + </div> + ); + + // โ”€โ”€โ”€ Dialog mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + if (isDialog) { + return ( + <> + <span onClick={() => !disabled && setOpen(true)}>{trigger}</span> + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="h-4 w-4" /> + <T>Select Groups</T> + </DialogTitle> + </DialogHeader> + {listContent} + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + <T>Cancel</T> + </Button> + <Button onClick={handleConfirm}> + <Check className="h-3 w-3 mr-1" /> + <T>Confirm</T> + {pendingIds.size > 0 && ( + <Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0"> + {pendingIds.size} + </Badge> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ); + } + + // โ”€โ”€โ”€ Inline mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + return listContent; +}; diff --git a/packages/ui/src/components/CreationWizardPopup.tsx b/packages/ui/src/components/CreationWizardPopup.tsx index 4548a733..80eabfa2 100644 --- a/packages/ui/src/components/CreationWizardPopup.tsx +++ b/packages/ui/src/components/CreationWizardPopup.tsx @@ -11,7 +11,7 @@ import VoiceRecordingPopup from './VoiceRecordingPopup'; import AIPageGenerator from '@/modules/pages/AIPageGenerator'; -import PostPicker from './PostPicker'; +import PostPicker from '@/modules/posts/components/PostPicker'; import { useWizardContext } from '@/hooks/useWizardContext'; import { usePromptHistory } from '@/hooks/usePromptHistory'; import { useAuth } from '@/hooks/useAuth'; @@ -19,6 +19,7 @@ import { useMediaRefresh } from '@/contexts/MediaRefreshContext'; import { createPicture } from '@/modules/posts/client-pictures'; import { fetchPostById } from '@/modules/posts/client-posts'; import { toast } from 'sonner'; +import { supabase } from '@/integrations/supabase/client'; interface CreationWizardPopupProps { isOpen: boolean; @@ -106,7 +107,7 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ } }); } else { - navigate('/wizard', { state: { mode: 'post' } }); + navigate('/post/new'); } }; @@ -182,7 +183,17 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ setShowVoicePopup(true); }; - const handleGeneratePageFromText = async (options: { useImageTools: boolean; model: string; imageModel?: string; referenceImages?: string[] }) => { + const handleGeneratePageFromText = async (options: { + useImageTools: boolean; + model?: string; + provider?: string; + imageModel?: string; + referenceImages?: string[]; + webSearchEnabled?: boolean; + pageToolsEnabled?: boolean; + vfsToolsEnabled?: boolean; + toolsEnabled?: boolean; + }) => { if (!textPrompt.trim()) return; addPromptToHistory(textPrompt); @@ -242,6 +253,8 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ const file = img.file; if (!file) continue; + // Skip video files โ€“ they must go through the video upload path + if (file.type.startsWith('video/')) continue; const { publicUrl } = await uploadImage(file, user.id); console.log('image uploaded, url:', publicUrl); @@ -274,6 +287,9 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ // Helper function to upload internal video const uploadInternalVideo = async (file: File): Promise<void> => { + // Get auth token before creating the XHR + const { data: { session: authSession } } = await supabase.auth.getSession(); + return new Promise((resolve, reject) => { const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL; const formData = new FormData(); @@ -283,6 +299,9 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ const xhr = new XMLHttpRequest(); xhr.open('POST', `${serverUrl}/api/videos/upload?userId=${user?.id}&title=${encodeURIComponent(title)}&preset=original`); + if (authSession?.access_token) { + xhr.setRequestHeader('Authorization', `Bearer ${authSession.access_token}`); + } xhr.upload.onprogress = (e) => { if (e.lengthComputable) { @@ -361,12 +380,13 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ }; const handleImageFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => { - const file = event.target.files?.[0]; - if (!file) return; + const fileList = event.target.files; + if (!fileList || fileList.length === 0) return; - // Validate file type - if (!file.type.startsWith('image/')) { - toast.error(translate('Please select a valid image file')); + // Filter to valid image files + const files = Array.from(fileList).filter(f => f.type.startsWith('image/')); + if (files.length === 0) { + toast.error(translate('Please select valid image files')); return; } @@ -378,37 +398,29 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ setIsUploadingImage(true); try { - // Upload file to storage - // Upload file to storage (direct or via proxy) - const { publicUrl } = await uploadImage(file, user.id); - - // Get organization ID if in org context let organizationId = null; - // Save picture metadata to database - const imageTitle = file.name.replace(/\.[^/.]+$/, ''); // Remove extension - await createPicture({ - user_id: user.id, - title: imageTitle, - description: null, - image_url: publicUrl, - organization_id: organizationId, - } as any); - toast.success(translate('Image uploaded successfully!')); + for (const file of files) { + const { publicUrl } = await uploadImage(file, user.id); + const imageTitle = file.name.replace(/\.[^/.]+$/, ''); + await createPicture({ + user_id: user.id, + title: imageTitle, + description: null, + image_url: publicUrl, + organization_id: organizationId, + } as any); + } - // Trigger PhotoGrid refresh + toast.success(translate(`${files.length} image(s) uploaded successfully!`)); triggerRefresh(); - onClose(); - - // Navigate to home to see the uploaded image navigate('/'); - } catch (error) { + } catch (error: any) { console.error('Error uploading image:', error); - toast.error(translate('Failed to upload image')); + toast.error(translate(error?.message || 'Failed to upload image')); } finally { setIsUploadingImage(false); - // Clear the input so the same file can be selected again if (imageInputRef.current) { imageInputRef.current.value = ''; } @@ -588,6 +600,7 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({ ref={imageInputRef} type="file" accept="image/*" + multiple onChange={handleImageFileSelected} className="hidden" /> diff --git a/packages/ui/src/components/EditImageModal.tsx b/packages/ui/src/components/EditImageModal.tsx index 6cf85e3c..688aabcf 100644 --- a/packages/ui/src/components/EditImageModal.tsx +++ b/packages/ui/src/components/EditImageModal.tsx @@ -173,7 +173,7 @@ const EditImageModal = ({ setUpdating(true); try { await updatePicture(pictureId, { - title: data.title?.trim() || null, + title: data.title?.trim() || '', description: data.description || null, visible: data.visible, updated_at: new Date().toISOString(), @@ -552,7 +552,7 @@ const EditImageModal = ({ <TabsContent value="versions" className="mt-4"> <VersionSelector currentPictureId={pictureId} - onVersionSelect={onUpdateSuccess} + onVersionSelect={() => { /* stay open โ€” user may toggle multiple versions */ }} /> </TabsContent> </Tabs> diff --git a/packages/ui/src/components/EditingToolbar.tsx b/packages/ui/src/components/EditingToolbar.tsx new file mode 100644 index 00000000..a393d7b1 --- /dev/null +++ b/packages/ui/src/components/EditingToolbar.tsx @@ -0,0 +1,481 @@ +import React, { Suspense, lazy } from 'react'; +import { + ArrowUp, ArrowDown, Upload, FileText, Sparkles, Mic, MicOff, Plus, Trash2, Save, History, Wand2 +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { T, translate } from "@/i18n"; +import { ImageLightboxEditorConfig } from './ImageLightboxTypes'; + +const StylePresetSelector = lazy(() => import('./StylePresetSelector').then(m => ({ default: m.StylePresetSelector }))); + +export interface EditingToolbarProps { + config: ImageLightboxEditorConfig; + onHide: () => void; +} + +export function EditingToolbar({ config, onHide }: EditingToolbarProps) { + const { + showWizardFeatures, + lightboxPrompt, + setLightboxPrompt, + isGenerating, + isPublishing, + isOptimizing, + isRecording, + isTranscribing, + onPromptSubmit, + onMicrophoneToggle, + onManualPromptEdit, + promptTemplates = [], + onApplyTemplate, + onSaveTemplate, + onDeleteTemplate, + onOptimizePrompt, + onOpenInWizard, + onNavigateHistory, + promptHistory = [], + historyIndex = -1, + showPublish, + showQuickPublish, + onQuickPublish, + onPublishClick, + quickActions = [] + } = config; + + return ( + <div + className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-[90vw] md:w-[80vw] max-w-4xl z-40" + onClick={(e) => { + // Allow clicking outside the prompt container to hide it + if (e.target === e.currentTarget) { + onHide(); + } + }} + > + <div + className="bg-black/80 backdrop-blur-sm rounded-xl p-3 md:p-4 shadow-2xl border border-white/20" + onClick={(e) => e.stopPropagation()} + > + {showWizardFeatures ? ( + /* Wizard Mode: Textarea + Separate Actions Row */ + <> + {/* Prompt History Indicator */} + {promptHistory.length > 0 && ( + <div className="flex items-center justify-between mb-2 text-xs"> + <span className="text-white/60"> + History: {historyIndex >= 0 ? `${historyIndex + 1}/${promptHistory.length}` : 'Current'} + </span> + <div className="flex items-center gap-1"> + {/* History Picker Dropdown */} + <DropdownMenu modal={false}> + <DropdownMenuTrigger asChild> + <Button + type="button" + variant="ghost" + onClick={(e) => e.stopPropagation()} + className="h-6 w-6 p-0 text-white hover:text-primary" + title="Browse prompt history" + > + <History className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-80 max-h-64 overflow-y-auto z-[10000]"> + {promptHistory.length === 0 ? ( + <div className="px-2 py-3 text-sm text-muted-foreground text-center"> + <T>No history yet</T> + </div> + ) : ( + <> + {[...promptHistory].reverse().map((historyPrompt, reverseIndex) => { + const actualIndex = promptHistory.length - 1 - reverseIndex; + return ( + <DropdownMenuItem + key={actualIndex} + onSelect={() => { + setLightboxPrompt(historyPrompt); + }} + className={`flex flex-col items-start gap-1 py-2 ${historyIndex === actualIndex ? 'bg-primary/10' : '' + }`} + > + <div className="text-xs text-muted-foreground"> + #{promptHistory.length - reverseIndex} + {historyIndex === actualIndex && ' (current)'} + </div> + <div className="text-sm line-clamp-2 w-full"> + {historyPrompt} + </div> + </DropdownMenuItem> + ); + })} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + <Button + type="button" + variant="ghost" + onClick={(e) => { + e.stopPropagation(); + onNavigateHistory?.('up'); + }} + disabled={promptHistory.length === 0 || historyIndex === promptHistory.length - 1} + className="h-6 w-6 p-0 text-white hover:text-primary disabled:opacity-30" + title="Previous prompt (Ctrl+โ†‘)" + > + <ArrowUp className="h-3 w-3" /> + </Button> + <Button + type="button" + variant="ghost" + onClick={(e) => { + e.stopPropagation(); + onNavigateHistory?.('down'); + }} + disabled={historyIndex === -1} + className="h-6 w-6 p-0 text-white hover:text-primary disabled:opacity-30" + title="Next prompt (Ctrl+โ†“)" + > + <ArrowUp className="h-3 w-3 rotate-180" /> + </Button> + </div> + </div> + )} + + {/* Input Row */} + <div className="flex gap-2 mb-2 md:mb-0"> + <div className="flex-1 relative"> + <Textarea + value={lightboxPrompt} + onChange={(e) => { + setLightboxPrompt(e.target.value); + onManualPromptEdit?.(); // Reset history index when manually typing + }} + placeholder={translate("Quick edit prompt...")} + disabled={isGenerating} + rows={2} + className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 pr-10 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none text-sm md:text-base" + onKeyDown={(e) => { + if ((e.key === 'Enter' && e.ctrlKey) || (e.key === 'Enter' && !e.shiftKey && lightboxPrompt.trim() && !isGenerating)) { + e.stopPropagation(); + e.preventDefault(); + onPromptSubmit(); + if (!e.ctrlKey) { + setLightboxPrompt(''); + } + } else if (e.key === 'ArrowUp' && e.ctrlKey) { + e.stopPropagation(); + e.preventDefault(); + onNavigateHistory?.('up'); + } else if (e.key === 'ArrowDown' && e.ctrlKey) { + e.stopPropagation(); + e.preventDefault(); + onNavigateHistory?.('down'); + } else if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + setLightboxPrompt(''); + } else if (e.key.startsWith('Arrow') || e.key === 'Delete' || e.key === 'Backspace') { + e.stopPropagation(); + } + }} + onClick={(e) => e.stopPropagation()} + /> + <button + onClick={(e) => { + e.stopPropagation(); + onMicrophoneToggle?.(); + }} + disabled={isTranscribing} + className={`absolute right-2 bottom-2 p-1 rounded-md transition-colors ${isRecording + ? 'bg-red-600/80 text-white hover:bg-red-700' + : 'text-white/60 hover:text-white hover:bg-white/10' + }`} + title={isRecording ? 'Stop recording' : 'Record audio'} + > + {isTranscribing ? ( + <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div> + ) : isRecording ? ( + <MicOff size={16} /> + ) : ( + <Mic size={16} /> + )} + </button> + </div> + </div> + + {/* Style Presets - Minimal Row */} + <div className="mb-2 mt-2"> + <Suspense fallback={null}> + <StylePresetSelector + presets={quickActions} + onSelect={(preset) => { + const current = lightboxPrompt || ''; + const trimmed = current.trim(); + const separator = trimmed ? ', ' : ''; + setLightboxPrompt(`${trimmed}${separator}${preset.prompt}`); + }} + variant="minimal" + disabled={isGenerating} + /> + </Suspense> + </div> + + {/* Actions Row - Separate on mobile */} + <div className="flex gap-2 flex-wrap md:flex-nowrap"> + {/* Templates Dropdown */} + <DropdownMenu modal={false}> + <DropdownMenuTrigger asChild> + <Button + onClick={(e) => e.stopPropagation()} + variant="ghost" + className="text-white hover:text-primary disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title={translate("Prompt Templates")} + > + <FileText size={20} className="md:w-5 md:h-5" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-64 z-[10000]"> + {promptTemplates.length === 0 ? ( + <div className="px-2 py-3 text-sm text-muted-foreground text-center"> + <T>No templates saved yet</T> + </div> + ) : ( + <> + {promptTemplates.map((template, index) => ( + <DropdownMenuItem + key={index} + onSelect={() => onApplyTemplate?.(template.template)} + className="flex items-center justify-between group" + > + <span className="flex-1 truncate">{template.name}</span> + <button + onClick={(e) => { + e.stopPropagation(); + onDeleteTemplate?.(index); + }} + className="opacity-0 group-hover:opacity-100 ml-2 p-1 hover:bg-destructive/20 rounded" + title={translate("Delete template")} + > + <Trash2 className="h-3 w-3 text-destructive" /> + </button> + </DropdownMenuItem> + ))} + <DropdownMenuSeparator /> + </> + )} + <DropdownMenuItem onSelect={() => onSaveTemplate?.()}> + <Plus className="h-3 w-3 mr-2" /> + <T>Save current as template</T> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + {onOpenInWizard && ( + <Button + onClick={(e) => { + e.stopPropagation(); + onOpenInWizard(); + }} + variant="ghost" + className="text-white hover:text-purple-400 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title={translate("Open in AI Wizard for advanced editing")} + > + <Wand2 size={20} className="md:w-5 md:h-5" /> + </Button> + )} + <Button + onClick={(e) => { + e.stopPropagation(); + onOptimizePrompt?.(); + }} + disabled={isOptimizing || !lightboxPrompt.trim()} + variant="ghost" + className="text-white hover:text-primary disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title={translate("Optimize prompt with AI")} + > + {isOptimizing ? ( + <div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div> + ) : ( + <Sparkles size={20} className="md:w-5 md:h-5" /> + )} + </Button> + <Button + onClick={(e) => { + e.stopPropagation(); + if (lightboxPrompt.trim() && !isGenerating) { + onPromptSubmit(); + setLightboxPrompt(''); + } + }} + disabled={!lightboxPrompt.trim() || isGenerating} + variant="ghost" + className="text-primary hover:text-primary/80 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title="Generate (Enter to submit)" + > + {isGenerating ? ( + <div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent"></div> + ) : ( + <ArrowUp size={20} className="md:w-5 md:h-5" /> + )} + </Button> + {showQuickPublish && ( + <Button + onClick={(e) => { + e.stopPropagation(); + onQuickPublish?.(); + }} + disabled={isPublishing} + variant="ghost" + className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title="Quick Publish with prompt as description" + > + {isPublishing ? ( + <div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div> + ) : ( + <Upload size={20} className="md:w-5 md:h-5" /> + )} + </Button> + )} + {showPublish && ( + <Button + onClick={(e) => { + e.stopPropagation(); + onPublishClick(); + }} + disabled={isPublishing} + variant="ghost" + className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title="Publish to gallery with options" + > + {isPublishing ? ( + <div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div> + ) : ( + <Save size={20} className="md:w-5 md:h-5" /> + )} + </Button> + )} + <Button + onClick={(e) => { + e.stopPropagation(); + onHide(); + }} + variant="ghost" + className="text-white/60 hover:text-white p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0 md:ml-auto" + title={translate("Hide prompt")} + type="button" + > + <ArrowDown size={20} className="md:w-5 md:h-5" /> + </Button> + </div> + </> + ) : ( + /* Simple Mode: Same layout as wizard, fewer buttons */ + <> + {/* Input Row */} + <div className="flex gap-2 mb-2 md:mb-0"> + <div className="flex-1 relative"> + <Textarea + value={lightboxPrompt} + onChange={(e) => { + setLightboxPrompt(e.target.value); + onManualPromptEdit?.(); // Reset history index when manually typing + }} + placeholder={translate("Quick edit prompt...")} + disabled={isGenerating} + rows={2} + className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none text-sm md:text-base" + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && lightboxPrompt.trim() && !isGenerating) { + e.stopPropagation(); + e.preventDefault(); + onPromptSubmit(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + setLightboxPrompt(''); + } else if (e.key.startsWith('Arrow') || e.key === 'Delete' || e.key === 'Backspace') { + e.stopPropagation(); + } + }} + onClick={(e) => e.stopPropagation()} + /> + </div> + </div> + + {/* Style Presets - Minimal Row (Simple Mode) */} + <div className="mb-2 mt-2"> + <Suspense fallback={null}> + <StylePresetSelector + presets={quickActions} + onSelect={(preset) => { + const current = lightboxPrompt || ''; + const trimmed = current.trim(); + const separator = trimmed ? ', ' : ''; + setLightboxPrompt(`${trimmed}${separator}${preset.prompt}`); + }} + variant="minimal" + disabled={isGenerating} + /> + </Suspense> + </div> + + {/* Actions Row - Simple mode */} + <div className="flex gap-2"> + <Button + onClick={(e) => { + e.stopPropagation(); + if (lightboxPrompt.trim() && !isGenerating) { + onPromptSubmit(); + } + }} + disabled={!lightboxPrompt.trim() || isGenerating} + variant="ghost" + className="text-primary hover:text-primary/80 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title="Generate (Enter to submit)" + > + {isGenerating ? ( + <div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent"></div> + ) : ( + <ArrowUp size={20} className="md:w-5 md:h-5" /> + )} + </Button> + {showPublish && ( + <Button + onClick={(e) => { + e.stopPropagation(); + onPublishClick(); + }} + disabled={isPublishing} + variant="ghost" + className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title="Publish to gallery" + > + {isPublishing ? ( + <div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div> + ) : ( + <Save size={20} className="md:w-5 md:h-5" /> + )} + </Button> + )} + <Button + onClick={(e) => { + e.stopPropagation(); + onHide(); + }} + variant="ghost" + className="text-white/60 hover:text-white p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" + title={translate("Hide prompt")} + type="button" + > + <ArrowDown size={20} className="md:w-5 md:h-5" /> + </Button> + </div> + </> + )} + </div> + </div> + ); +} diff --git a/packages/ui/src/components/EditorActions.tsx b/packages/ui/src/components/EditorActions.tsx index ba2540c2..926c38a3 100644 --- a/packages/ui/src/components/EditorActions.tsx +++ b/packages/ui/src/components/EditorActions.tsx @@ -15,6 +15,7 @@ export function InsertCustomImage({ onRequestImage }: InsertCustomImageProps) { const { addLog } = useLog(); const handleClick = async () => { + console.log('InsertCustomImage: Button clicked'); try { addLog('info', 'InsertCustomImage: Requesting image...'); const imageUrl = await onRequestImage(); @@ -29,6 +30,7 @@ export function InsertCustomImage({ onRequestImage }: InsertCustomImageProps) { addLog('error', 'InsertCustomImage: imageUrl is empty/undefined'); } } catch (error) { + console.error('InsertCustomImage: Failed to insert image', error); addLog('error', `InsertCustomImage: Failed to insert image - ${error}`); } }; @@ -89,7 +91,7 @@ export function MicrophoneTranscribe({ onTranscribed }: MicrophoneTranscribeProp try { addLog('info', 'MicrophoneTranscribe: Transcribing audio with Whisper...'); const transcription = await transcribeAudio(audioFile, 'whisper-1'); - + if (transcription) { addLog('success', `MicrophoneTranscribe: Transcription successful (${transcription.length} chars)`); onTranscribed(transcription); @@ -123,11 +125,10 @@ export function MicrophoneTranscribe({ onTranscribed }: MicrophoneTranscribeProp <button onClick={handleToggle} disabled={isTranscribing} - className={`flex items-center justify-center p-1 rounded transition-colors ${ - isRecording - ? 'bg-red-100 text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' - : 'hover:bg-accent' - }`} + className={`flex items-center justify-center p-1 rounded transition-colors ${isRecording + ? 'bg-red-100 text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' + : 'hover:bg-accent' + }`} title={isTranscribing ? 'Transcribing...' : isRecording ? 'Stop recording' : 'Record audio'} > {isTranscribing ? ( @@ -168,7 +169,7 @@ interface SaveButtonProps { export function SaveButton({ onSave }: SaveButtonProps) { const { addLog } = useLog(); - + if (!onSave) return null; const handleClick = () => { diff --git a/packages/ui/src/components/Footer.tsx b/packages/ui/src/components/Footer.tsx index cf6d8f28..f9bfd1cc 100644 --- a/packages/ui/src/components/Footer.tsx +++ b/packages/ui/src/components/Footer.tsx @@ -1,28 +1,27 @@ import { useAppConfig } from '@/hooks/useSystemInfo'; +import { T } from '@/i18n'; const policyLinks = [ { label: "Returns & Refunds", href: "/returns" }, { label: "Shipping", href: "/shipping" }, { label: "Privacy Policy", href: "/privacy" }, { label: "Terms of Service", href: "/terms" }, + { label: "Impressum", href: "/impressum" }, ]; const Footer = () => { const config = useAppConfig(); - if (!config) return null; - const { footer_left = [], footer_right = [], metadata } = config; const allLinks = [...footer_left, ...footer_right]; - return ( - <footer className="border-t border-border/40 bg-muted/30 mt-auto"> + <footer className="border-t border-border/40 bg-muted/30"> <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-6"> {/* Column 1: Navigation links */} {allLinks.length > 0 && ( <nav className="flex flex-col gap-2"> - <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/60 mb-1">Links</span> + <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/60 mb-1"><T>Links</T></span> {allLinks.map((link, i) => { const isExternal = link.href.startsWith('http'); return ( @@ -33,7 +32,7 @@ const Footer = () => { rel={isExternal ? 'noopener noreferrer' : undefined} className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - {link.text} + <T>{link.text}</T> </a> ); })} @@ -42,14 +41,14 @@ const Footer = () => { {/* Column 2: Policy links */} <nav className="flex flex-col gap-2"> - <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/60 mb-1">Policies</span> + <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/60 mb-1"><T>Policies</T></span> {policyLinks.map((l) => ( <a key={l.href} href={l.href} className="text-sm text-muted-foreground hover:text-foreground hover:underline transition-colors" > - {l.label} + <T>{l.label}</T> </a> ))} </nav> diff --git a/packages/ui/src/components/GalleryLarge.tsx b/packages/ui/src/components/GalleryLarge.tsx index ae548182..dbd036c6 100644 --- a/packages/ui/src/components/GalleryLarge.tsx +++ b/packages/ui/src/components/GalleryLarge.tsx @@ -2,7 +2,7 @@ import MediaCard from "./MediaCard"; import React, { useEffect, useState, useRef, useCallback } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useNavigate } from "react-router-dom"; -import { usePostNavigation } from "@/hooks/usePostNavigation"; + import { useOrganization } from "@/contexts/OrganizationContext"; import { useFeedData } from "@/hooks/useFeedData"; import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry"; @@ -14,12 +14,16 @@ import type { FeedSortOption } from '@/hooks/useFeedData'; import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts"; interface GalleryLargeProps { - customPictures?: MediaItem[]; + customPictures?: any[]; customLoading?: boolean; - navigationSource?: 'home' | 'collection' | 'tag' | 'user'; + navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'search'; navigationSourceId?: string; sortBy?: FeedSortOption; categorySlugs?: string[]; + categoryIds?: string[]; + contentType?: 'posts' | 'pages' | 'pictures' | 'files'; + visibilityFilter?: 'invisible' | 'private'; + center?: boolean; } const GalleryLarge = ({ @@ -28,13 +32,17 @@ const GalleryLarge = ({ navigationSource = 'home', navigationSourceId, sortBy = 'latest', - categorySlugs + categorySlugs, + categoryIds, + contentType, + visibilityFilter, + center }: GalleryLargeProps) => { const { user } = useAuth(); const navigate = useNavigate(); - const { setNavigationData, navigationData } = usePostNavigation(); + const { orgSlug, isOrgContext } = useOrganization(); - const [mediaItems, setMediaItems] = useState<MediaItem[]>([]); + const [mediaItems, setMediaItems] = useState<any[]>([]); const [userLikes, setUserLikes] = useState<Set<string>>(new Set()); const [loading, setLoading] = useState(true); @@ -46,13 +54,16 @@ const GalleryLarge = ({ orgSlug, sortBy, categorySlugs, + categoryIds, + contentType, + visibilityFilter, // Disable hook if we have custom pictures enabled: !customPictures }); // 2. State & Effects useEffect(() => { - let finalMedia: MediaItem[] = []; + let finalMedia: any[] = []; if (customPictures) { finalMedia = customPictures; @@ -64,24 +75,7 @@ const GalleryLarge = ({ } setMediaItems(finalMedia || []); - - // Update Navigation Data - if (finalMedia && finalMedia.length > 0) { - const navData = { - posts: finalMedia.map(item => ({ - id: item.id, - title: item.title, - image_url: item.image_url, - user_id: item.user_id, - type: normalizeMediaType(item.type) - })), - currentIndex: 0, - source: navigationSource, - sourceId: navigationSourceId - }; - setNavigationData(navData); - } - }, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]); + }, [feedPosts, feedLoading, customPictures, customLoading, sortBy]); const refreshUserLikes = useCallback(async () => { if (!user || mediaItems.length === 0) return; diff --git a/packages/ui/src/components/GmailIntegrations.tsx b/packages/ui/src/components/GmailIntegrations.tsx new file mode 100644 index 00000000..b1815862 --- /dev/null +++ b/packages/ui/src/components/GmailIntegrations.tsx @@ -0,0 +1,631 @@ +/** + * GmailIntegrations - manage connected IMAP mailboxes in Profile > Integrations + * Supports both OAuth2 (Connect with Google) and App Password flows. + */ +import React, { useState, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Mail, + Plus, + Trash2, + Loader2, + CheckCircle2, + AlertCircle, + Wifi, + WifiOff, + Eye, + EyeOff, + KeyRound, + RotateCcw, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + listMailboxes, + saveMailbox, + deleteMailbox, + testMailbox, + getGmailAuthUrl, + syncMailbox, + stopSyncMailbox, + resetSyncMailbox, + type MailboxItem, + type MailboxInput, +} from '@/modules/contacts/client-mailboxes'; +import { fetchContactGroups, createContactGroup, type ContactGroup } from '@/modules/contacts/client-contacts'; +import { useStream } from '@/contexts/StreamContext'; + +// Server base โ€” requests go through Vite proxy /api โ†’ :3333 +const SERVER_BASE = ''; + +// โ”€โ”€โ”€ Status badge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function StatusBadge({ status }: { status?: string }) { + if (status === 'syncing') return <Badge variant="secondary" className="gap-1 bg-blue-100 text-blue-700 hover:bg-blue-100 border-blue-200"><Loader2 className="h-3 w-3 animate-spin" /> Syncing...</Badge>; + if (status === 'synced') return <Badge variant="secondary" className="gap-1 bg-green-100 text-green-700 hover:bg-green-100 border-green-200"><CheckCircle2 className="h-3 w-3" /> Synced</Badge>; + if (status === 'ok') return <Badge variant="default" className="gap-1"><CheckCircle2 className="h-3 w-3" /> Connected</Badge>; + if (status === 'error') return <Badge variant="destructive" className="gap-1"><AlertCircle className="h-3 w-3" /> Error</Badge>; + return <Badge variant="secondary" className="gap-1"><WifiOff className="h-3 w-3" /> Not tested</Badge>; +} + +// โ”€โ”€โ”€ Auth-type badge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function AuthBadge({ item }: { item: MailboxItem }) { + if ((item as any).has_oauth2) return <Badge variant="outline" className="gap-1 text-xs"><CheckCircle2 className="h-3 w-3 text-green-500" />OAuth2</Badge>; + if (item.has_password) return <Badge variant="outline" className="gap-1 text-xs"><KeyRound className="h-3 w-3" />App Password</Badge>; + return <Badge variant="outline" className="text-xs text-muted-foreground">No credentials</Badge>; +} + +// โ”€โ”€โ”€ Add Mailbox Dialog (App Password path) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface AddMailboxDialogProps { + open: boolean; + onOpenChange: (v: boolean) => void; + onSaved: () => void; +} + +const EMPTY_FORM: MailboxInput = { + label: '', + host: 'imap.gmail.com', + port: 993, + tls: true, + user: '', + password: '', +}; + +const AddMailboxDialog: React.FC<AddMailboxDialogProps> = ({ open, onOpenChange, onSaved }) => { + const [form, setForm] = useState<MailboxInput>(EMPTY_FORM); + const [showPass, setShowPass] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { if (open) { setForm(EMPTY_FORM); setShowPass(false); } }, [open]); + + const handleSave = async () => { + if (!form.label.trim()) return toast.error('Label is required'); + if (!form.host.trim()) return toast.error('Host is required'); + if (!form.user.trim()) return toast.error('Email / username is required'); + if (!form.password?.trim()) return toast.error('App Password is required'); + + setSaving(true); + try { + await saveMailbox(form); + toast.success('Mailbox saved'); + onSaved(); + onOpenChange(false); + } catch (err: any) { + toast.error(err.message || 'Failed to save mailbox'); + } finally { + setSaving(false); + } + }; + + const set = (key: keyof MailboxInput, value: any) => setForm(prev => ({ ...prev, [key]: value })); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-lg"> + <DialogHeader> + <DialogTitle>Add IMAP Mailbox (App Password)</DialogTitle> + <DialogDescription> + For Gmail, generate an{' '} + <a href="https://myaccount.google.com/apppasswords" target="_blank" rel="noreferrer" className="underline text-primary"> + App Password + </a>{' '} + and use it below. Or use the OAuth2 button instead. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-2"> + <div className="space-y-2"> + <Label htmlFor="mb-label">Label</Label> + <Input id="mb-label" value={form.label} onChange={e => set('label', e.target.value)} placeholder="e.g. Work Gmail" /> + </div> + <div className="grid grid-cols-3 gap-3"> + <div className="col-span-2 space-y-2"> + <Label htmlFor="mb-host">IMAP Host</Label> + <Input id="mb-host" value={form.host} onChange={e => set('host', e.target.value)} placeholder="imap.gmail.com" /> + </div> + <div className="space-y-2"> + <Label htmlFor="mb-port">Port</Label> + <Input id="mb-port" type="number" value={form.port} onChange={e => set('port', Number(e.target.value))} /> + </div> + </div> + <div className="flex items-center gap-2"> + <Switch id="mb-tls" checked={form.tls} onCheckedChange={v => set('tls', v)} /> + <Label htmlFor="mb-tls">Use TLS / IMAPS</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="mb-user">Email / Username</Label> + <Input id="mb-user" type="email" value={form.user} onChange={e => set('user', e.target.value)} placeholder="you@gmail.com" /> + </div> + <div className="space-y-2"> + <Label htmlFor="mb-pass">App Password</Label> + <div className="relative"> + <Input + id="mb-pass" + type={showPass ? 'text' : 'password'} + value={form.password || ''} + onChange={e => set('password', e.target.value)} + placeholder="xxxx xxxx xxxx xxxx" + className="pr-10" + /> + <Button type="button" variant="ghost" size="sm" className="absolute right-0 top-0 h-full px-3" onClick={() => setShowPass(p => !p)}> + {showPass ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} + </Button> + </div> + <p className="text-xs text-muted-foreground">Stored securely โ€” never returned after saving.</p> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>Cancel</Button> + <Button onClick={handleSave} disabled={saving}> + {saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Savingโ€ฆ</> : 'Save Mailbox'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}; + +// โ”€โ”€โ”€ Sync Mailbox Dialog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface SyncMailboxDialogProps { + open: boolean; + onOpenChange: (v: boolean) => void; + mailbox: MailboxItem | null; + groups: ContactGroup[]; + onGroupCreated: (g: ContactGroup) => void; + onStartSync: (mailboxId: string, groupId: string | undefined, deepScan: boolean) => void; +} + +const SyncMailboxDialog: React.FC<SyncMailboxDialogProps> = ({ open, onOpenChange, mailbox, groups, onGroupCreated, onStartSync }) => { + const [importGroupId, setImportGroupId] = useState<string>('__none'); + const [importNewGroupName, setImportNewGroupName] = useState<string>(''); + const [deepScan, setDeepScan] = useState(false); + + useEffect(() => { + if (open) { + setImportGroupId('__none'); + const d = new Date(); + setImportNewGroupName(`group-${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}`); + setDeepScan(false); + } + }, [open]); + + const handleSync = async () => { + if (!mailbox) return; + let targetGroupId: string | undefined = undefined; + + if (importGroupId === '__new') { + const groupName = importNewGroupName.trim(); + if (!groupName) return toast.error('Group name required'); + try { + const newGroup = await createContactGroup({ name: groupName }); + onGroupCreated(newGroup); + targetGroupId = newGroup.id; + } catch (err: any) { + return toast.error(`Failed to create group: ${err.message}`); + } + } else if (importGroupId !== '__none') { + targetGroupId = importGroupId; + } + + onStartSync(mailbox.id, targetGroupId, deepScan); + onOpenChange(false); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>Sync Contacts from {mailbox?.label}</DialogTitle> + <DialogDescription className="text-sm text-muted-foreground pt-1.5"> + Harvest email addresses from this mailbox and add them to your contacts. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-2"> + <div className="space-y-2"> + <Label>Destination Group</Label> + <Select value={importGroupId} onValueChange={setImportGroupId}> + <SelectTrigger> + <SelectValue placeholder="None" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="__none">None (Don't Group)</SelectItem> + <SelectItem value="__new">+ Create New Group</SelectItem> + {groups.map(g => ( + <SelectItem key={g.id} value={g.id}>{g.name}</SelectItem> + ))} + </SelectContent> + </Select> + </div> + {importGroupId === '__new' && ( + <div className="space-y-2 animate-in fade-in slide-in-from-top-2"> + <Label>New Group Name</Label> + <Input + value={importNewGroupName} + onChange={e => setImportNewGroupName(e.target.value)} + placeholder="Group name" + /> + </div> + )} + <div className="flex items-center space-x-2 pt-2"> + <Checkbox id="deep-scan" checked={deepScan} onCheckedChange={(v) => setDeepScan(!!v)} /> + <label + htmlFor="deep-scan" + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + Deep Scan (Slower) + </label> + </div> + {deepScan && ( + <p className="text-xs text-muted-foreground"> + Downloads full email bodies to search for email addresses and website URLs in signatures and text. Can take a long time on large mailboxes! + </p> + )} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button> + <Button onClick={handleSync}>Start Syncing</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}; + +// โ”€โ”€โ”€ Main component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const GmailIntegrations: React.FC = () => { + const [mailboxes, setMailboxes] = useState<MailboxItem[]>([]); + const [loading, setLoading] = useState(true); + const [addOpen, setAddOpen] = useState(false); + const [testing, setTesting] = useState<string | null>(null); + const [deleting, setDeleting] = useState<string | null>(null); + const location = useLocation(); + + // Sync State + const [groups, setGroups] = useState<ContactGroup[]>([]); + const [syncDialogOpen, setSyncDialogOpen] = useState(false); + const [syncMailboxTarget, setSyncMailboxTarget] = useState<MailboxItem | null>(null); + const [syncProgress, setSyncProgress] = useState<Record<string, { status: string; current?: number; total?: number; message?: string }>>({}); + + // Stream listener + const { subscribe } = useStream(); + useEffect(() => { + return subscribe((event) => { + if (event.kind === 'system' && event.data?.type === 'contacts-sync-progress') { + const { mailboxId, status, current, total, message } = event.data; + setSyncProgress(prev => ({ + ...prev, + [mailboxId]: { + status, current, total, message + } + })); + + // Update the mailbox's internal `status` for the StatusBadge to reflect + setMailboxes(prev => prev.map(mb => { + if (mb.id === mailboxId) { + let newStatus = mb.status; + if (status === 'completed') newStatus = 'synced'; + else if (status === 'error') newStatus = 'error'; + else newStatus = 'syncing'; // fetching, connecting, etc + return { ...mb, status: newStatus as any }; + } + return mb; + })); + } + }); + }, [subscribe]); + + // Handle OAuth2 callback result via URL params + useEffect(() => { + const params = new URLSearchParams(location.search); + const gmail = params.get('gmail'); + const msg = params.get('msg'); + if (gmail === 'connected') { + toast.success('Gmail connected successfully!'); + // Clean the URL + window.history.replaceState({}, '', location.pathname); + } else if (gmail === 'error') { + toast.error('Gmail connection failed: ' + (msg || 'Unknown error')); + window.history.replaceState({}, '', location.pathname); + } + }, [location]); + + const handleStopSync = async (id: string) => { + try { + await stopSyncMailbox(id); + toast.success('Stop signal sent. Wait a moment for the process to halt.'); + } catch (e: any) { + toast.error(e.message); + } + }; + + const handleResetSync = async (id: string) => { + try { + await resetSyncMailbox(id); + toast.success('Sync state reset. Next sync will start from the beginning.'); + setSyncProgress(prev => { const n = { ...prev }; delete n[id]; return n; }); + await load(); + } catch (e: any) { + toast.error(e.message); + } + }; + + const load = async () => { + setLoading(true); + try { + const list = await listMailboxes(); + setMailboxes(list.filter(mb => mb.type !== 'smtp')); + const gs = await fetchContactGroups(); + setGroups(gs); + } catch (err: any) { + toast.error('Failed to load mailboxes: ' + err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const handleStartSync = async (mailboxId: string, groupId: string | undefined, deepScan: boolean) => { + try { + setSyncProgress(prev => ({ ...prev, [mailboxId]: { status: 'initializing' } })); + await syncMailbox(mailboxId, { groupId, deepScan }); + toast.success('Background sync started!'); + } catch (err: any) { + toast.error(`Failed to start sync: ${err.message}`); + setSyncProgress(prev => { + const next = { ...prev }; + delete next[mailboxId]; + return next; + }); + } + }; + + const handleConnectGoogle = async () => { + try { + const { url } = await getGmailAuthUrl(); + if (url) window.location.href = url; + } catch (err: any) { + toast.error('Failed to start Google OAuth flow: ' + err.message); + } + }; + + const handleTest = async (id: string) => { + setTesting(id); + try { + const result = await testMailbox(id); + if (result.ok) toast.success('Connection successful!'); + else toast.error('Connection failed: ' + (result.error || 'Unknown error')); + await load(); + } catch (err: any) { + toast.error(err.message); + } finally { + setTesting(null); + } + }; + + const handleDelete = async (id: string) => { + setDeleting(id); + try { + await deleteMailbox(id); + toast.success('Mailbox removed'); + setMailboxes(prev => prev.filter(m => m.id !== id)); + } catch (err: any) { + toast.error(err.message); + } finally { + setDeleting(null); + } + }; + + return ( + <div className="space-y-6"> + {/* Header */} + <div className="flex items-center justify-between"> + <div> + <h2 className="text-xl font-semibold">Connected Mailboxes</h2> + <p className="text-sm text-muted-foreground mt-0.5"> + IMAP mailboxes for contact harvesting. Credentials stored securely. + </p> + </div> + <div className="flex gap-2"> + <Button onClick={handleConnectGoogle} size="sm" variant="outline" className="gap-2"> + {/* Google G logo โ€” inline SVG to avoid extra deps */} + <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none"> + <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" /> + <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /> + <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" /> + <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /> + </svg> + Connect Gmail + </Button> + <Button onClick={() => setAddOpen(true)} size="sm" variant="outline"> + <Plus className="h-4 w-4 mr-1" /> App Password + </Button> + </div> + </div> + + {/* List */} + {loading ? ( + <div className="flex items-center justify-center py-12"> + <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : mailboxes.length === 0 ? ( + <Card> + <CardContent className="flex flex-col items-center justify-center py-12 text-center"> + <Mail className="h-12 w-12 text-muted-foreground mb-4 opacity-40" /> + <h3 className="font-semibold text-lg mb-1">No mailboxes connected</h3> + <p className="text-sm text-muted-foreground mb-4"> + Connect a Gmail inbox to start harvesting contacts from your emails. + </p> + <div className="flex gap-2"> + <Button onClick={handleConnectGoogle} size="sm" className="gap-2"> + <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none"> + <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" /> + <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /> + <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" /> + <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /> + </svg> + Connect Gmail (OAuth2) + </Button> + </div> + </CardContent> + </Card> + ) : ( + <div className="grid gap-4 md:grid-cols-2"> + {mailboxes.map(mb => { + const progress = syncProgress[mb.id]; + const isSyncing = progress && !['completed', 'error'].includes(progress.status); + + return ( + <Card key={mb.id} className="relative overflow-hidden"> + {isSyncing && progress.total ? ( + <div className="absolute top-0 left-0 w-full h-1 bg-muted"> + <div + className="h-full bg-primary transition-all duration-500 ease-out" + style={{ width: `${Math.min(100, (progress.current! / progress.total!) * 100)}%` }} + /> + </div> + ) : null} + <CardHeader className="pb-3 pt-5"> + <div className="flex items-start justify-between"> + <div className="flex items-center gap-3"> + <div className="p-2 rounded-lg bg-primary/10"> + <Mail className="h-5 w-5 text-primary" /> + </div> + <div> + <CardTitle className="text-base">{mb.label}</CardTitle> + <CardDescription className="text-xs">{mb.user}</CardDescription> + </div> + </div> + <div className="flex gap-1"> + <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleTest(mb.id)} disabled={testing === mb.id || isSyncing} title="Test connection"> + {testing === mb.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />} + </Button> + <Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-destructive hover:text-destructive" onClick={() => handleDelete(mb.id)} disabled={deleting === mb.id || isSyncing} title="Remove mailbox"> + {deleting === mb.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />} + </Button> + </div> + </div> + </CardHeader> + <CardContent className="space-y-3 text-sm"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">Status</span> + <StatusBadge status={mb.status} /> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">Auth</span> + <AuthBadge item={mb} /> + </div> + </div> + + <Separator /> + + <div className="flex items-center justify-between pt-1"> + {isSyncing ? ( + <div className="flex flex-col gap-1 w-full mr-4"> + <div className="flex items-center text-xs text-primary font-medium animate-pulse"> + <Loader2 className="h-3 w-3 animate-spin mr-1.5" /> + {progress.status === 'initializing' && 'Preparing sync...'} + {progress.status === 'caching-contacts' && 'Loading address book...'} + {progress.status === 'connecting' && 'Connecting to IMAP...'} + {progress.message ? progress.message : (progress.status === 'fetching' ? 'Fetching emails...' : '')} + </div> + </div> + ) : ( + <div className="text-xs text-muted-foreground w-full mr-4"> + {progress?.status === 'completed' && <span className="text-green-600 flex items-center"><CheckCircle2 className="h-3 w-3 mr-1" /> {progress.message || 'Sync complete'}</span>} + {progress?.status === 'error' && <span className="text-destructive">Sync failed: {progress.message}</span>} + </div> + )} + + <div className="flex items-center gap-2"> + {isSyncing ? ( + <Button + size="sm" + variant="secondary" + className="shrink-0 text-red-500 hover:text-red-600 bg-red-50 hover:bg-red-100 border-red-200" + onClick={() => handleStopSync(mb.id)} + > + Stop Sync + </Button> + ) : ( + <> + <Button + size="sm" + variant="ghost" + className="shrink-0 h-8 w-8 p-0 text-muted-foreground" + title="Reset sync state (re-scan from beginning)" + onClick={() => handleResetSync(mb.id)} + > + <RotateCcw className="h-3.5 w-3.5" /> + </Button> + <Button + size="sm" + variant="secondary" + className="shrink-0" + disabled={mb.status === 'error'} + onClick={() => { setSyncMailboxTarget(mb); setSyncDialogOpen(true); }} + > + Sync Contacts + </Button> + </> + )} + </div> + </div> + </CardContent> + </Card> + ); + })} + </div> + )} + + {/* Info card */} + <Card className="bg-muted/40 border-dashed"> + <CardContent className="pt-4 text-sm text-muted-foreground space-y-1"> + <p className="font-medium text-foreground">Connection options</p> + <p><strong>OAuth2 (recommended):</strong> Click "Connect Gmail" โ€” no password needed, Google authorizes directly.</p> + <p><strong>App Password:</strong> Enable IMAP in Gmail โ†’ Settings โ†’ Forwarding and POP/IMAP, then generate a password at <a href="https://myaccount.google.com/apppasswords" target="_blank" rel="noreferrer" className="underline">myaccount.google.com/apppasswords</a>.</p> + <p className="pt-1 text-xs">Host: <code className="bg-muted px-1 rounded">imap.gmail.com</code> ยท Port: <code className="bg-muted px-1 rounded">993</code> ยท TLS: on</p> + </CardContent> + </Card> + + <AddMailboxDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} /> + <SyncMailboxDialog + open={syncDialogOpen} + onOpenChange={setSyncDialogOpen} + mailbox={syncMailboxTarget} + groups={groups} + onGroupCreated={(g) => setGroups(prev => [...prev, g])} + onStartSync={handleStartSync} + /> + </div> + ); +}; diff --git a/packages/ui/src/components/ImageLightbox.tsx b/packages/ui/src/components/ImageLightbox.tsx index 0cee39e6..5d6d9824 100644 --- a/packages/ui/src/components/ImageLightbox.tsx +++ b/packages/ui/src/components/ImageLightbox.tsx @@ -1,62 +1,30 @@ -import { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo, Suspense, lazy } from 'react'; import { createPortal } from 'react-dom'; import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; -import { ArrowUp, ArrowDown, Upload, Info, FileText, Sparkles, Mic, MicOff, Plus, Trash2, Save, History, Wand2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import PublishDialog from '@/components/PublishDialog'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { T, translate } from "@/i18n"; -import { QuickAction, DEFAULT_QUICK_ACTIONS } from "@/constants"; -import { StylePresetSelector } from "./StylePresetSelector"; +import { DEFAULT_QUICK_ACTIONS } from "@/constants"; +import { ImageLightboxEditorConfig } from './ImageLightboxTypes'; +const ImageLightboxPrompt = lazy(() => import('./ImageLightboxPrompt').then(m => ({ default: m.ImageLightboxPrompt }))); +const PublishDialog = lazy(() => import('@/components/PublishDialog')); interface ImageLightboxProps { isOpen: boolean; onClose: () => void; imageUrl: string; imageTitle: string; originalImageId?: string; - onPromptSubmit?: (prompt: string) => void; onPublish?: (option: 'overwrite' | 'new' | 'version', imageUrl: string, title: string, description?: string, parentId?: string, collectionIds?: string[]) => void; - isGenerating?: boolean; - isPublishing?: boolean; showPrompt?: boolean; - showPublish?: boolean; generatedImageUrl?: string; // Navigation props currentIndex?: number; totalCount?: number; onNavigate?: (direction: 'prev' | 'next') => void; onPreload?: (direction: 'prev' | 'next') => void; - // Wizard features (optional) - showWizardFeatures?: boolean; - promptTemplates?: Array<{ name: string; template: string }>; - onApplyTemplate?: (template: string) => void; - onSaveTemplate?: () => void; - onDeleteTemplate?: (index: number) => void; - onOptimizePrompt?: () => void; - isOptimizing?: boolean; - onMicrophoneToggle?: () => void; - isRecording?: boolean; - isTranscribing?: boolean; - showQuickPublish?: boolean; - onQuickPublish?: () => void; - prompt?: string; - onPromptChange?: (value: string) => void; - quickActions?: QuickAction[]; - // Prompt history - promptHistory?: string[]; - historyIndex?: number; - onNavigateHistory?: (direction: 'up' | 'down') => void; - onManualPromptEdit?: () => void; // Callback to reset history index when user manually types + // Editor Config + editorConfig?: ImageLightboxEditorConfig; // Wizard navigation onOpenInWizard?: () => void; // Open current image in full wizard + inline?: boolean; } import ResponsiveImage from './ResponsiveImage'; @@ -67,51 +35,23 @@ export default function ImageLightbox({ imageUrl, imageTitle, originalImageId, - onPromptSubmit, onPublish, - isGenerating = false, - isPublishing = false, showPrompt = true, - showPublish = false, generatedImageUrl, currentIndex, totalCount, onNavigate, - onPreload, - // Wizard features - showWizardFeatures = false, - promptTemplates = [], - onApplyTemplate, - onSaveTemplate, - onDeleteTemplate, - onOptimizePrompt, - isOptimizing = false, - onMicrophoneToggle, - isRecording = false, - isTranscribing = false, - showQuickPublish = false, - onQuickPublish, - prompt: externalPrompt, - onPromptChange, - // Prompt history - promptHistory = [], - historyIndex = -1, - onNavigateHistory, - onManualPromptEdit, - // Wizard navigation + editorConfig, onOpenInWizard, - quickActions = DEFAULT_QUICK_ACTIONS + inline = false }: ImageLightboxProps) { const [lightboxLoaded, setLightboxLoaded] = useState(false); const [isPanning, setIsPanning] = useState(false); const [scale, setScale] = useState(1); - const [internalPrompt, setInternalPrompt] = useState(''); const [showPublishDialog, setShowPublishDialog] = useState(false); const [showPromptField, setShowPromptField] = useState(false); const [showInfoPopup, setShowInfoPopup] = useState(false); - const panStartRef = useRef<{ x: number; y: number } | null>(null); const tapTimeoutRef = useRef<NodeJS.Timeout | null>(null); - const lastTapRef = useRef<number>(0); const swipeStartRef = useRef<{ x: number; y: number; time: number } | null>(null); const isSwipingRef = useRef(false); const isPanningRef = useRef(false); @@ -127,27 +67,22 @@ export default function ImageLightbox({ return () => mq.removeEventListener('change', handler); }, []); - // Use external prompt if provided (controlled), otherwise internal state (uncontrolled) - const lightboxPrompt = externalPrompt !== undefined ? externalPrompt : internalPrompt; - const setLightboxPrompt = (value: string) => { - if (onPromptChange) { - onPromptChange(value); - } else { - setInternalPrompt(value); - } - }; + // Extract values that ImageLightbox needs directly from editorConfig + const { + onNavigateHistory, + } = editorConfig || {}; // Preload image when lightbox opens useEffect(() => { - if (isOpen) { + if (isOpen || inline) { // Ensure prompt field is hidden by default when opening setShowPromptField(false); } - }, [isOpen]); + }, [isOpen, inline]); // Handle keyboard events useEffect(() => { - if (!isOpen) return; + if (!isOpen && !inline) return; const handleKeyDown = (event: KeyboardEvent) => { @@ -240,13 +175,6 @@ export default function ImageLightbox({ isSwipingRef.current = false; }; - const handlePromptSubmit = () => { - if (lightboxPrompt.trim() && !isGenerating && onPromptSubmit) { - onPromptSubmit(lightboxPrompt); - // Don't clear the prompt - keep it for reference/reuse - } - }; - const handlePublishClick = () => { setShowPublishDialog(true); }; @@ -259,24 +187,64 @@ export default function ImageLightbox({ setShowPublishDialog(false); }; - if (!isOpen) return null; + // Provide the publish click handler internally to editorConfig if missing + const activeEditorConfig = React.useMemo(() => { + if (!editorConfig) return undefined; + return { + ...editorConfig, + onPublishClick: editorConfig.onPublishClick || handlePublishClick, + onOpenInWizard: editorConfig.onOpenInWizard || onOpenInWizard, + quickActions: editorConfig.quickActions || DEFAULT_QUICK_ACTIONS + }; + }, [editorConfig, onOpenInWizard]); - const responsiveImageEl = ( + // Determine if it's a video + const isVideo = useMemo(() => { + const url = (generatedImageUrl || imageUrl || '').toLowerCase(); + const title = (imageTitle || '').toLowerCase(); + return url.match(/\.(mp4|webm|ogg|mov)$/) || title.match(/\.(mp4|webm|ogg|mov)$/); + }, [generatedImageUrl, imageUrl, imageTitle]); + + if (!isOpen && !inline) return null; + + const mediaEl = isVideo ? ( + <video + src={generatedImageUrl || imageUrl} + controls + autoPlay + loop + className={`object-contain pointer-events-auto ${inline ? 'max-w-full max-h-full' : 'max-w-[100vw] max-h-[100dvh] md:max-w-[90vw] md:max-h-[90vh]'}`} + onClick={(e) => { + // Only trigger navigation if they click outside the actual video controls region + // but it's hard to distinguish. Typically preventing propagation is safer, + // but we'll leave standard behavior of next image on video click unless hitting controls. + }} + onLoadedData={() => setLightboxLoaded(true)} + onTouchStart={(e: React.TouchEvent) => { + const touch = e.touches[0]; + handleSwipeStart(touch.clientX, touch.clientY); + }} + onTouchEnd={(e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + handleSwipeEnd(touch.clientX, touch.clientY); + }} + /> + ) : ( <ResponsiveImage src={generatedImageUrl || imageUrl} alt={imageTitle} sizes={`${Math.ceil(scale * 100)}vw`} - responsiveSizes={[640, 1024]} - imgClassName="max-w-[100vw] max-h-[100dvh] object-contain pointer-events-auto md:max-w-[90vw] md:max-h-[90vh] md:cursor-grab md:active:cursor-grabbing" + responsiveSizes={[800]} + imgClassName={`object-contain pointer-events-auto ${inline ? 'max-w-full max-h-full' : 'max-w-[100vw] max-h-[100dvh] md:max-w-[90vw] md:max-h-[90vh] md:cursor-grab md:active:cursor-grabbing'}`} className="flex items-center justify-center" loading="eager" draggable={false} onLoad={() => setLightboxLoaded(true)} onClick={(e: React.MouseEvent) => { - // Only toggle controls if we haven't been panning - if (!isPanningRef.current && showPrompt) { + // Click on image = next image + if (!isPanningRef.current && onNavigate && !inline) { e.stopPropagation(); - setShowPromptField(!showPromptField); + onNavigate('next'); } }} onTouchStart={(e: React.TouchEvent) => { @@ -290,6 +258,149 @@ export default function ImageLightbox({ /> ); + const innerContent = ( + <div + className="relative w-full h-full flex items-center justify-center min-w-0 min-h-0 overflow-hidden" + onClick={(e) => { + // Close when clicking the dark area outside the image + if (e.target === e.currentTarget && !inline) { + onClose(); + } + }} + > + <div + className="relative w-full h-full flex items-center justify-center min-w-0 min-h-0" + onClick={(e) => { + if (e.target === e.currentTarget && !inline) { + onClose(); + } + }} + > + {isMobile ? mediaEl : ( + <div + onClick={(e) => { + // Navigate to next image on click, but only at scale=1 (not zoomed) + if (!isVideo && scale <= 1 && !isPanningRef.current && onNavigate) { + e.stopPropagation(); + onNavigate('next'); + } + }} + style={{ cursor: scale <= 1 ? 'pointer' : 'grab' }} + > + <TransformWrapper + initialScale={1} + minScale={1} + maxScale={40} + centerOnInit={true} + centerZoomedOut={true} + limitToBounds={true} + disabled={!!isVideo} + + alignmentAnimation={{ animationTime: 200, animationType: 'easeOut' }} + wheel={{ step: 1 }} + doubleClick={{ disabled: false, step: 0.7 }} + pinch={{ step: 20 }} + onTransformed={(e) => setScale(e.state.scale)} + > + <TransformComponent + wrapperClass="w-full h-full" + contentClass="" + > + {mediaEl} + </TransformComponent> + </TransformWrapper> + </div> + )} + </div> + {!lightboxLoaded && ( + <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> + <div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin"></div> + </div> + )} + + {/* Close Button */} + {!inline && ( + <button + onClick={(e) => { + e.stopPropagation(); + onClose(); + }} + className="absolute top-4 right-4 text-white text-2xl p-4 bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200 z-20" + title="Close (ESC)" + > + ร— + </button> + )} + + {/* Navigation Buttons - show if navigation function exists and we have valid navigation data */} + {onNavigate && currentIndex !== undefined && totalCount !== undefined && totalCount > 1 && !inline && ( + <> + {currentIndex > 0 && ( + <button + onClick={(e) => { + e.stopPropagation(); + onNavigate('prev'); + }} + className="absolute left-4 top-1/2 transform -translate-y-1/2 p-2 text-white text-xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200" + style={{ zIndex: 50 }} + title="Previous (โ†)" + > + โ€น + </button> + )} + + {currentIndex < totalCount - 1 && ( + <button + onClick={(e) => { + e.stopPropagation(); + onNavigate('next'); + }} + className="absolute right-4 top-1/2 transform -translate-y-1/2 p-2 text-white text-xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200" + style={{ zIndex: 50 }} + title="Next (โ†’)" + > + โ€บ + </button> + )} + </> + )} + + {/* the prompt UI wrapper includes the wand button, the editing toolbar, and info popup */} + <Suspense fallback={null}> + <ImageLightboxPrompt + config={activeEditorConfig} + showPrompt={!!showPrompt} + showPromptField={showPromptField} + setShowPromptField={setShowPromptField} + lightboxLoaded={lightboxLoaded} + + showInfoPopup={showInfoPopup} + setShowInfoPopup={setShowInfoPopup} + imageTitle={imageTitle} + currentIndex={currentIndex} + totalCount={totalCount} + onNavigate={onNavigate} + /> + </Suspense> + </div > + ); + + if (inline) { + return ( + <> + {innerContent} + <PublishDialog + isOpen={showPublishDialog} + onClose={() => setShowPublishDialog(false)} + onPublish={handlePublish} + originalTitle={imageTitle} + originalImageId={originalImageId} + isPublishing={editorConfig?.isPublishing} + /> + </> + ); + } + return createPortal( <div className="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center" @@ -318,582 +429,16 @@ export default function ImageLightbox({ } }} > - <div className="relative w-full h-full flex items-center justify-center min-w-0 min-h-0 overflow-hidden"> - <div className="relative w-full h-full flex items-center justify-center min-w-0 min-h-0"> - {isMobile ? responsiveImageEl : ( - <TransformWrapper - initialScale={1} - minScale={1} - maxScale={40} - centerOnInit={true} - centerZoomedOut={true} - limitToBounds={true} - - alignmentAnimation={{ animationTime: 200, animationType: 'easeOut' }} - wheel={{ step: 1 }} - doubleClick={{ disabled: false, step: 0.7 }} - pinch={{ step: 20 }} - onTransformed={(e) => setScale(e.state.scale)} - > - <TransformComponent - wrapperClass="w-full h-full" - contentClass="" - > - {responsiveImageEl} - </TransformComponent> - </TransformWrapper> - )} - </div> - {!lightboxLoaded && ( - <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"> - <div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin"></div> - </div> - )} - - {/* Close Button */} - <button - onClick={(e) => { - e.stopPropagation(); - onClose(); - }} - className="absolute top-4 right-4 text-white text-2xl p-4 bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200 z-20" - title="Close (ESC)" - > - ร— - </button> - - {/* Navigation Buttons - show if navigation function exists and we have valid navigation data */} - {onNavigate && currentIndex !== undefined && totalCount !== undefined && totalCount > 1 && ( - <> - {currentIndex > 0 && ( - <button - onClick={(e) => { - e.stopPropagation(); - onNavigate('prev'); - }} - className="absolute left-4 top-1/2 transform -translate-y-1/2 p-4 text-white text-3xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200" - style={{ zIndex: 50 }} - title="Previous (โ†)" - > - โ€น - </button> - )} - - {currentIndex < totalCount - 1 && ( - <button - onClick={(e) => { - e.stopPropagation(); - onNavigate('next'); - }} - className="absolute right-4 top-1/2 transform -translate-y-1/2 p-4 text-white text-3xl bg-black/75 rounded-lg hover:bg-black/90 transition-all duration-200" - style={{ zIndex: 50 }} - title="Next (โ†’)" - > - โ€บ - </button> - )} - </> - )} - - {/* Quick Prompt Field */} - {showPrompt && showPromptField && lightboxLoaded && ( - <div - className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-[90vw] md:w-[80vw] max-w-4xl z-40" - onClick={(e) => { - // Allow clicking outside the prompt container to hide it - if (e.target === e.currentTarget) { - setShowPromptField(false); - } - }} - > - <div - className="bg-black/80 backdrop-blur-sm rounded-xl p-3 md:p-4 shadow-2xl border border-white/20" - onClick={(e) => e.stopPropagation()} - > - {showWizardFeatures ? ( - /* Wizard Mode: Textarea + Separate Actions Row */ - <> - {/* Prompt History Indicator */} - {promptHistory.length > 0 && ( - <div className="flex items-center justify-between mb-2 text-xs"> - <span className="text-white/60"> - History: {historyIndex >= 0 ? `${historyIndex + 1}/${promptHistory.length}` : 'Current'} - </span> - <div className="flex items-center gap-1"> - {/* History Picker Dropdown */} - <DropdownMenu modal={false}> - <DropdownMenuTrigger asChild> - <Button - type="button" - variant="ghost" - onClick={(e) => e.stopPropagation()} - className="h-6 w-6 p-0 text-white hover:text-primary" - title="Browse prompt history" - > - <History className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-80 max-h-64 overflow-y-auto z-[10000]"> - {promptHistory.length === 0 ? ( - <div className="px-2 py-3 text-sm text-muted-foreground text-center"> - <T>No history yet</T> - </div> - ) : ( - <> - {[...promptHistory].reverse().map((historyPrompt, reverseIndex) => { - const actualIndex = promptHistory.length - 1 - reverseIndex; - return ( - <DropdownMenuItem - key={actualIndex} - onSelect={() => { - setLightboxPrompt(historyPrompt); - }} - className={`flex flex-col items-start gap-1 py-2 ${historyIndex === actualIndex ? 'bg-primary/10' : '' - }`} - > - <div className="text-xs text-muted-foreground"> - #{promptHistory.length - reverseIndex} - {historyIndex === actualIndex && ' (current)'} - </div> - <div className="text-sm line-clamp-2 w-full"> - {historyPrompt} - </div> - </DropdownMenuItem> - ); - })} - </> - )} - </DropdownMenuContent> - </DropdownMenu> - <Button - type="button" - variant="ghost" - onClick={(e) => { - e.stopPropagation(); - onNavigateHistory?.('up'); - }} - disabled={promptHistory.length === 0 || historyIndex === promptHistory.length - 1} - className="h-6 w-6 p-0 text-white hover:text-primary disabled:opacity-30" - title="Previous prompt (Ctrl+โ†‘)" - > - <ArrowUp className="h-3 w-3" /> - </Button> - <Button - type="button" - variant="ghost" - onClick={(e) => { - e.stopPropagation(); - onNavigateHistory?.('down'); - }} - disabled={historyIndex === -1} - className="h-6 w-6 p-0 text-white hover:text-primary disabled:opacity-30" - title="Next prompt (Ctrl+โ†“)" - > - <ArrowUp className="h-3 w-3 rotate-180" /> - </Button> - </div> - </div> - )} - - {/* Input Row */} - <div className="flex gap-2 mb-2 md:mb-0"> - <div className="flex-1 relative"> - <Textarea - value={lightboxPrompt} - onChange={(e) => { - setLightboxPrompt(e.target.value); - onManualPromptEdit?.(); // Reset history index when manually typing - }} - placeholder={translate("Quick edit prompt...")} - disabled={isGenerating} - rows={2} - className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 pr-10 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none text-sm md:text-base" - onKeyDown={(e) => { - if ((e.key === 'Enter' && e.ctrlKey) || (e.key === 'Enter' && !e.shiftKey && lightboxPrompt.trim() && !isGenerating)) { - e.stopPropagation(); - e.preventDefault(); - handlePromptSubmit(); - if (!e.ctrlKey) { - setLightboxPrompt(''); - } - } else if (e.key === 'ArrowUp' && e.ctrlKey) { - e.stopPropagation(); - e.preventDefault(); - onNavigateHistory?.('up'); - } else if (e.key === 'ArrowDown' && e.ctrlKey) { - e.stopPropagation(); - e.preventDefault(); - onNavigateHistory?.('down'); - } else if (e.key === 'Escape') { - e.stopPropagation(); - e.preventDefault(); - setLightboxPrompt(''); - } else if (e.key.startsWith('Arrow') || e.key === 'Delete' || e.key === 'Backspace') { - e.stopPropagation(); - } - }} - onClick={(e) => e.stopPropagation()} - /> - <button - onClick={(e) => { - e.stopPropagation(); - onMicrophoneToggle?.(); - }} - disabled={isTranscribing} - className={`absolute right-2 bottom-2 p-1 rounded-md transition-colors ${isRecording - ? 'bg-red-600/80 text-white hover:bg-red-700' - : 'text-white/60 hover:text-white hover:bg-white/10' - }`} - title={isRecording ? 'Stop recording' : 'Record audio'} - > - {isTranscribing ? ( - <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div> - ) : isRecording ? ( - <MicOff size={16} /> - ) : ( - <Mic size={16} /> - )} - </button> - </div> - </div> - - {/* Style Presets - Minimal Row */} - <div className="mb-2 mt-2"> - <StylePresetSelector - presets={quickActions} - onSelect={(preset) => { - const current = lightboxPrompt || ''; - const trimmed = current.trim(); - const separator = trimmed ? ', ' : ''; - setLightboxPrompt(`${trimmed}${separator}${preset.prompt}`); - }} - variant="minimal" - disabled={isGenerating} - /> - </div> - - {/* Actions Row - Separate on mobile */} - <div className="flex gap-2 flex-wrap md:flex-nowrap"> - {/* Templates Dropdown */} - <DropdownMenu modal={false}> - <DropdownMenuTrigger asChild> - <Button - onClick={(e) => e.stopPropagation()} - variant="ghost" - className="text-white hover:text-primary disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title={translate("Prompt Templates")} - > - <FileText size={20} className="md:w-5 md:h-5" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-64 z-[10000]"> - {promptTemplates.length === 0 ? ( - <div className="px-2 py-3 text-sm text-muted-foreground text-center"> - <T>No templates saved yet</T> - </div> - ) : ( - <> - {promptTemplates.map((template, index) => ( - <DropdownMenuItem - key={index} - onSelect={() => onApplyTemplate?.(template.template)} - className="flex items-center justify-between group" - > - <span className="flex-1 truncate">{template.name}</span> - <button - onClick={(e) => { - e.stopPropagation(); - onDeleteTemplate?.(index); - }} - className="opacity-0 group-hover:opacity-100 ml-2 p-1 hover:bg-destructive/20 rounded" - title={translate("Delete template")} - > - <Trash2 className="h-3 w-3 text-destructive" /> - </button> - </DropdownMenuItem> - ))} - <DropdownMenuSeparator /> - </> - )} - <DropdownMenuItem onSelect={() => onSaveTemplate?.()}> - <Plus className="h-3 w-3 mr-2" /> - <T>Save current as template</T> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - {onOpenInWizard && ( - <Button - onClick={(e) => { - e.stopPropagation(); - onOpenInWizard(); - }} - variant="ghost" - className="text-white hover:text-purple-400 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title={translate("Open in AI Wizard for advanced editing")} - > - <Wand2 size={20} className="md:w-5 md:h-5" /> - </Button> - )} - <Button - onClick={(e) => { - e.stopPropagation(); - onOptimizePrompt?.(); - }} - disabled={isOptimizing || !lightboxPrompt.trim()} - variant="ghost" - className="text-white hover:text-primary disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title={translate("Optimize prompt with AI")} - > - {isOptimizing ? ( - <div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div> - ) : ( - <Sparkles size={20} className="md:w-5 md:h-5" /> - )} - </Button> - <Button - onClick={(e) => { - e.stopPropagation(); - if (lightboxPrompt.trim() && !isGenerating) { - handlePromptSubmit(); - setLightboxPrompt(''); - } - }} - disabled={!lightboxPrompt.trim() || isGenerating} - variant="ghost" - className="text-primary hover:text-primary/80 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title="Generate (Enter to submit)" - > - {isGenerating ? ( - <div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent"></div> - ) : ( - <ArrowUp size={20} className="md:w-5 md:h-5" /> - )} - </Button> - {showQuickPublish && ( - <Button - onClick={(e) => { - e.stopPropagation(); - onQuickPublish?.(); - }} - disabled={isPublishing} - variant="ghost" - className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title="Quick Publish with prompt as description" - > - {isPublishing ? ( - <div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div> - ) : ( - <Upload size={20} className="md:w-5 md:h-5" /> - )} - </Button> - )} - {showPublish && ( - <Button - onClick={(e) => { - e.stopPropagation(); - handlePublishClick(); - }} - disabled={isPublishing} - variant="ghost" - className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title="Publish to gallery with options" - > - {isPublishing ? ( - <div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div> - ) : ( - <Save size={20} className="md:w-5 md:h-5" /> - )} - </Button> - )} - <Button - onClick={(e) => { - e.stopPropagation(); - setShowPromptField(false); - }} - variant="ghost" - className="text-white/60 hover:text-white p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0 md:ml-auto" - title={translate("Hide prompt")} - type="button" - > - <ArrowDown size={20} className="md:w-5 md:h-5" /> - </Button> - </div> - </> - ) : ( - /* Simple Mode: Same layout as wizard, fewer buttons */ - <> - {/* Input Row */} - <div className="flex gap-2 mb-2 md:mb-0"> - <div className="flex-1 relative"> - <Textarea - value={lightboxPrompt} - onChange={(e) => { - setLightboxPrompt(e.target.value); - onManualPromptEdit?.(); // Reset history index when manually typing - }} - placeholder={translate("Quick edit prompt...")} - disabled={isGenerating} - rows={2} - className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent backdrop-blur-sm disabled:opacity-50 resize-none text-sm md:text-base" - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && lightboxPrompt.trim() && !isGenerating) { - e.stopPropagation(); - e.preventDefault(); - handlePromptSubmit(); - } else if (e.key === 'Escape') { - e.stopPropagation(); - e.preventDefault(); - setLightboxPrompt(''); - } else if (e.key.startsWith('Arrow') || e.key === 'Delete' || e.key === 'Backspace') { - e.stopPropagation(); - } - }} - onClick={(e) => e.stopPropagation()} - /> - </div> - </div> - - {/* Style Presets - Minimal Row (Simple Mode) */} - <div className="mb-2 mt-2"> - <StylePresetSelector - presets={quickActions} - onSelect={(preset) => { - const current = lightboxPrompt || ''; - const trimmed = current.trim(); - const separator = trimmed ? ', ' : ''; - setLightboxPrompt(`${trimmed}${separator}${preset.prompt}`); - }} - variant="minimal" - disabled={isGenerating} - /> - </div> - - {/* Actions Row - Simple mode */} - <div className="flex gap-2"> - <Button - onClick={(e) => { - e.stopPropagation(); - if (lightboxPrompt.trim() && !isGenerating) { - handlePromptSubmit(); - } - }} - disabled={!lightboxPrompt.trim() || isGenerating} - variant="ghost" - className="text-primary hover:text-primary/80 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title="Generate (Enter to submit)" - > - {isGenerating ? ( - <div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent"></div> - ) : ( - <ArrowUp size={20} className="md:w-5 md:h-5" /> - )} - </Button> - {showPublish && ( - <Button - onClick={(e) => { - e.stopPropagation(); - handlePublishClick(); - }} - disabled={isPublishing} - variant="ghost" - className="text-green-500 hover:text-green-400 disabled:opacity-50 p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title="Publish to gallery" - > - {isPublishing ? ( - <div className="animate-spin rounded-full h-5 w-5 border-2 border-green-500 border-t-transparent"></div> - ) : ( - <Save size={20} className="md:w-5 md:h-5" /> - )} - </Button> - )} - <Button - onClick={(e) => { - e.stopPropagation(); - setShowPromptField(false); - }} - variant="ghost" - className="text-white/60 hover:text-white p-2.5 md:p-2 transition-colors duration-200 min-w-[44px] md:min-w-0" - title={translate("Hide prompt")} - type="button" - > - <ArrowDown size={20} className="md:w-5 md:h-5" /> - </Button> - </div> - </> - )} - </div> - </div> - ) - } - - {/* Prompt Toggle Button - shown when prompt is hidden */} - { - showPrompt && !showPromptField && lightboxLoaded && ( - <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-50"> - <button - onClick={(e) => { - e.stopPropagation(); - setShowPromptField(true); - }} - className="bg-primary/80 hover:bg-primary text-primary-foreground p-2 rounded-full shadow-lg transition-all duration-200 hover:scale-110" - title="Show prompt field (Space)" - > - <Wand2 size={20} /> - </button> - </div> - ) - } - - {/* Info Popup */} - { - showInfoPopup && lightboxLoaded && showPromptField && ( - <div - className="absolute bottom-16 left-1/2 transform -translate-x-1/2 bg-black/80 backdrop-blur-md text-white px-4 py-3 rounded-lg text-sm max-w-md text-center border border-white/20 z-50" - onClick={(e) => e.stopPropagation()} - > - {isPublishing ? ( - <div className="flex items-center justify-center gap-2"> - <div className="animate-spin rounded-full h-3 w-3 border-2 border-white border-t-transparent"></div> - <span>Publishing... โ€ข ESC to close</span> - </div> - ) : ( - <div className="space-y-2"> - <div className="font-medium">{imageTitle}</div> - {currentIndex !== undefined && totalCount !== undefined && ( - <div className="text-white/80 text-xs">{currentIndex + 1} of {totalCount}</div> - )} - <div className="text-white/70 text-xs space-y-1"> - <div>โ€ข Enter: generate</div> - <div>โ€ข Space/Click: hide prompt</div> - {showWizardFeatures && promptHistory.length > 0 && ( - <div>โ€ข Ctrl+โ†‘โ†“: prompt history</div> - )} - {onNavigate && totalCount && totalCount > 1 && ( - <div>โ€ข โ† โ†’ keys or swipe: navigate</div> - )} - <div>โ€ข ESC: close lightbox</div> - </div> - </div> - )} - {/* Close popup when clicking outside */} - <div - className="fixed inset-0 z-[-1]" - onClick={() => setShowInfoPopup(false)} - /> - </div> - ) - } - </div > - + {innerContent} <PublishDialog isOpen={showPublishDialog} onClose={() => setShowPublishDialog(false)} onPublish={handlePublish} originalTitle={imageTitle} originalImageId={originalImageId} - isPublishing={isPublishing} + isPublishing={editorConfig?.isPublishing} /> - </div >, + </div>, document.body ); } diff --git a/packages/ui/src/components/ImageLightboxPrompt.tsx b/packages/ui/src/components/ImageLightboxPrompt.tsx new file mode 100644 index 00000000..da4d3f9f --- /dev/null +++ b/packages/ui/src/components/ImageLightboxPrompt.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Wand2 } from 'lucide-react'; +import { EditingToolbar } from './EditingToolbar'; +import { ImageLightboxEditorConfig } from './ImageLightboxTypes'; + +export interface ImageLightboxPromptProps { + config?: ImageLightboxEditorConfig; + showPrompt: boolean; + showPromptField: boolean; + setShowPromptField: (show: boolean) => void; + lightboxLoaded: boolean; + + // Info popup props + showInfoPopup: boolean; + setShowInfoPopup: (show: boolean) => void; + imageTitle: string; + currentIndex?: number; + totalCount?: number; + onNavigate?: (direction: 'prev' | 'next') => void; +} + +export function ImageLightboxPrompt({ + config, + showPrompt, + showPromptField, + setShowPromptField, + lightboxLoaded, + + showInfoPopup, + setShowInfoPopup, + imageTitle, + currentIndex, + totalCount, + onNavigate, +}: ImageLightboxPromptProps) { + + if (!showPrompt || !lightboxLoaded) { + return null; + } + + // Safely extract config properties we need in this component + const { + isPublishing = false, + showWizardFeatures = false, + promptHistory = [] + } = config || {}; + + return ( + <> + {/* Quick Prompt Field */} + {showPromptField && config && ( + <EditingToolbar + config={config} + onHide={() => setShowPromptField(false)} + /> + )} + + {/* Prompt Toggle Button - shown when prompt is hidden */} + {!showPromptField && ( + <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-50"> + <button + onClick={(e) => { + e.stopPropagation(); + setShowPromptField(true); + }} + className="bg-primary/80 hover:bg-primary text-primary-foreground p-2 rounded-full shadow-lg transition-all duration-200 hover:scale-110" + title="Show prompt field (Space)" + > + <Wand2 size={20} /> + </button> + </div> + )} + + {/* Info Popup */} + {showInfoPopup && showPromptField && ( + <div + className="absolute bottom-16 left-1/2 transform -translate-x-1/2 bg-black/80 backdrop-blur-md text-white px-4 py-3 rounded-lg text-sm max-w-md text-center border border-white/20 z-50" + onClick={(e) => e.stopPropagation()} + > + {isPublishing ? ( + <div className="flex items-center justify-center gap-2"> + <div className="animate-spin rounded-full h-3 w-3 border-2 border-white border-t-transparent"></div> + <span>Publishing... โ€ข ESC to close</span> + </div> + ) : ( + <div className="space-y-2"> + <div className="font-medium">{imageTitle}</div> + {currentIndex !== undefined && totalCount !== undefined && ( + <div className="text-white/80 text-xs">{currentIndex + 1} of {totalCount}</div> + )} + <div className="text-white/70 text-xs space-y-1"> + <div>โ€ข Enter: generate</div> + <div>โ€ข Space/Click: hide prompt</div> + {showWizardFeatures && promptHistory.length > 0 && ( + <div>โ€ข Ctrl+โ†‘โ†“: prompt history</div> + )} + {onNavigate && totalCount && totalCount > 1 && ( + <div>โ€ข โ† โ†’ keys or swipe: navigate</div> + )} + <div>โ€ข ESC: close lightbox</div> + </div> + </div> + )} + {/* Close popup when clicking outside */} + <div + className="fixed inset-0 z-[-1]" + onClick={() => setShowInfoPopup(false)} + /> + </div> + )} + </> + ); +} diff --git a/packages/ui/src/components/ImageLightboxTypes.ts b/packages/ui/src/components/ImageLightboxTypes.ts new file mode 100644 index 00000000..1a577c4f --- /dev/null +++ b/packages/ui/src/components/ImageLightboxTypes.ts @@ -0,0 +1,35 @@ +import { QuickAction } from "@/constants"; + +export interface ImageLightboxEditorConfig { + showWizardFeatures?: boolean; + lightboxPrompt: string; + setLightboxPrompt: (value: string) => void; + isGenerating?: boolean; + isPublishing?: boolean; + isOptimizing?: boolean; + isRecording?: boolean; + isTranscribing?: boolean; + onPromptSubmit?: () => void; + onMicrophoneToggle?: () => void; + onManualPromptEdit?: () => void; + promptTemplates?: Array<{ name: string; template: string }>; + onApplyTemplate?: (template: string) => void; + onSaveTemplate?: () => void; + onDeleteTemplate?: (index: number) => void; + onOptimizePrompt?: () => void; + onOpenInWizard?: () => void; + + // Prompt history + onNavigateHistory?: (direction: 'up' | 'down') => void; + promptHistory?: string[]; + historyIndex?: number; + + // Publish + showPublish?: boolean; + showQuickPublish?: boolean; + onQuickPublish?: () => void; + onPublishClick?: () => void; + + // Quick actions + quickActions?: QuickAction[]; +} diff --git a/packages/ui/src/components/ImageWizard.tsx b/packages/ui/src/components/ImageWizard.tsx index 75ab7602..9711fb0d 100644 --- a/packages/ui/src/components/ImageWizard.tsx +++ b/packages/ui/src/components/ImageWizard.tsx @@ -46,7 +46,7 @@ import ImageLightbox from "@/components/ImageLightbox"; import { uploadInternalVideo } from '@/utils/uploadUtils'; import { ImageEditor } from "@/components/ImageEditor"; import EditImageModal from "@/components/EditImageModal"; -import PostPicker from "@/components/PostPicker"; +import PostPicker from "@/modules/posts/components/PostPicker"; // Import types and handlers import { ImageFile, ImageWizardProps } from "./ImageWizard/types"; @@ -95,8 +95,8 @@ import { WizardSidebar, Prompt, ImageGalleryPanel, - PostComposer, } from "./ImageWizard/components"; +import { PostComposer } from "@/modules/posts/components/PostComposer"; import { createLogger } from "./ImageWizard/utils/logger"; import { useImageWizardState } from "./ImageWizard/hooks/useImageWizardState"; import * as wizardDb from "./ImageWizard/db"; @@ -128,6 +128,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ mode = 'default', initialPostTitle = "", initialPostDescription = "", + initialPostSettings, editingPostId = undefined }) => { const { user } = useAuth(); @@ -137,12 +138,6 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ // Create logger instance for this component const logger = createLogger(addLog, 'ImageWizard'); - - - - // Don't render if not open - - // Centralized state management - flat destructuring const { // Image state @@ -160,6 +155,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ aspectRatio, setAspectRatio, resolution, setResolution, searchGrounding, setSearchGrounding, + imageSearch, setImageSearch, // Dialog state showDeleteConfirmDialog, setShowDeleteConfirmDialog, imageToDelete, setImageToDelete, showSaveTemplateDialog, setShowSaveTemplateDialog, newTemplateName, setNewTemplateName, @@ -182,7 +178,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ editingPostId: currentEditingPostId, } = useImageWizardState(initialImages, initialPostTitle, initialPostDescription, editingPostId); - const [settings, setSettings] = React.useState<any>({ visibility: 'public' }); // Post settings + const [settings, setSettings] = React.useState<any>(initialPostSettings || { visibility: 'public' }); // Post settings const [lastError, setLastError] = React.useState<string | null>(null); // Auto-retry state for 503 "high demand" errors @@ -242,9 +238,26 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ loadWorkflows(user.id, setWorkflows, setLoadingWorkflows); loadQuickActions(user.id, setQuickActions, setLoadingActions, DEFAULT_QUICK_ACTIONS); loadPromptHistory(user.id, setPromptHistory); + + // Load saved model selection from user_secrets + wizardDb.loadWizardModel(user.id).then(model => { + if (model) setSelectedModel(model); + }); } }, [user?.id]); + // Persist model selection when changed + const modelSaveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); + useEffect(() => { + if (!user?.id) return; + // Debounce to avoid saving on initial load + if (modelSaveTimeoutRef.current) clearTimeout(modelSaveTimeoutRef.current); + modelSaveTimeoutRef.current = setTimeout(() => { + wizardDb.saveWizardModel(user.id, selectedModel); + }, 500); + return () => { if (modelSaveTimeoutRef.current) clearTimeout(modelSaveTimeoutRef.current); }; + }, [selectedModel, user?.id]); + // Auto-upload effect for videos that come from share target (or other sources) without upload status React.useEffect(() => { // Find videos that have a file but no upload status (meaning they were just added via prop/state) @@ -661,7 +674,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ editingPostId: postId, initialImages: combinedImages, postTitle: post.title, - postDescription: post.description + postDescription: post.description, + postSettings: post.settings }, replace: true }); @@ -831,7 +845,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ apiKey, aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } else { // Convert any image source (data URL or HTTP URL) to File for editing @@ -848,7 +863,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ apiKey, aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } catch (error) { console.error('Error converting image:', error); @@ -1011,7 +1027,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ apiKey, aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } else { // Some images don't have File objects, convert all from src to ensure consistency @@ -1037,7 +1054,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ apiKey, aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } else { toast.error(translate('Failed to convert selected images for editing')); @@ -1052,7 +1070,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ apiKey, aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } @@ -1622,12 +1641,16 @@ const ImageWizard: React.FC<ImageWizardProps> = ({ <WizardSidebar selectedModel={selectedModel} onModelChange={setSelectedModel} + onLoadModel={wizardDb.loadWizardModel} + onSaveModel={wizardDb.saveWizardModel} aspectRatio={aspectRatio} onAspectRatioChange={setAspectRatio} resolution={resolution} onResolutionChange={setResolution} searchGrounding={searchGrounding} onSearchGroundingChange={setSearchGrounding} + imageSearch={imageSearch} + onImageSearchChange={setImageSearch} selectedPreset={selectedPreset} presets={promptPresets} loadingPresets={loadingPresets} diff --git a/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx b/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx index ad47de26..e604d54d 100644 --- a/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx +++ b/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx @@ -1,101 +1,3 @@ -import React from 'react'; -import { T } from '@/i18n'; - -import { AVAILABLE_MODELS, getModelString } from '@/lib/image-router'; - -interface ModelSelectorProps { - selectedModel: string; - onChange: (model: string) => void; - label?: string; - showStepNumber?: boolean; -} - -/** - * Model Selector Component - * - * Displays a dropdown to select AI image generation models, - * grouped by provider (Google, Replicate, Bria, AIML API). - */ -export const ModelSelector: React.FC<ModelSelectorProps> = ({ - selectedModel, - onChange, - label = 'AI Model', - showStepNumber = true, -}) => { - return ( - <div> - <label className="text-sm md:text-base font-medium mb-2 block"> - {showStepNumber && <span className="text-xs text-muted-foreground mr-2">1.</span>} - <T>{label}</T> - </label> - - <select - value={selectedModel} - onChange={(e) => onChange(e.target.value)} - className="w-full px-3 py-2.5 md:py-2 border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background" - > - {/* Google Gemini Models */} - <optgroup label="Google Gemini"> - {AVAILABLE_MODELS.filter(m => m.provider === 'google').map((model) => ( - <option - key={getModelString(model.provider, model.modelName)} - value={getModelString(model.provider, model.modelName)} - > - {model.displayName.replace('Google ', '')} - </option> - ))} - </optgroup> - - {/* Replicate Models */} - <optgroup label="Replicate"> - {AVAILABLE_MODELS.filter(m => m.provider === 'replicate').map((model) => ( - <option - key={getModelString(model.provider, model.modelName)} - value={getModelString(model.provider, model.modelName)} - > - {model.displayName.replace('Replicate ', '')} - </option> - ))} - </optgroup> - - {/* Bria.ai Models (Commercial Safe) */} - <optgroup label="Bria.ai (Commercial Safe)"> - {AVAILABLE_MODELS.filter(m => m.provider === 'bria').map((model) => ( - <option - key={getModelString(model.provider, model.modelName)} - value={getModelString(model.provider, model.modelName)} - > - {model.displayName.replace('Bria.ai ', '')} - </option> - ))} - </optgroup> - - {/* AIML API Models (Multi-Model Gateway) */} - <optgroup label="AIML API (Multi-Model Gateway)"> - {AVAILABLE_MODELS.filter(m => m.provider === 'aimlapi').map((model) => ( - <option - key={getModelString(model.provider, model.modelName)} - value={getModelString(model.provider, model.modelName)} - > - {model.displayName.replace('AIML API - ', '')} - </option> - ))} - </optgroup> - </select> - - <p className="text-xs text-muted-foreground mt-1"> - <T>Select the AI model for image generation</T> - </p> - </div> - ); -}; - -export default ModelSelector; - - - - - - - - +// Re-export from canonical location +export { ModelSelector } from '@/modules/ai/images/components/ModelSelector'; +export { default } from '@/modules/ai/images/components/ModelSelector'; diff --git a/packages/ui/src/components/ImageWizard/components/ModelSelectorPanel.tsx b/packages/ui/src/components/ImageWizard/components/ModelSelectorPanel.tsx new file mode 100644 index 00000000..31d66c6f --- /dev/null +++ b/packages/ui/src/components/ImageWizard/components/ModelSelectorPanel.tsx @@ -0,0 +1,3 @@ +// Re-export from canonical location +export { ModelSelectorPanel } from '@/modules/ai/images/components/ModelSelectorPanel'; +export { default } from '@/modules/ai/images/components/ModelSelectorPanel'; diff --git a/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx b/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx index 09e932aa..34bbaca5 100644 --- a/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx +++ b/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx @@ -8,11 +8,9 @@ import PresetManager from "@/components/PresetManager"; import { Workflow } from "@/components/WorkflowManager"; import WorkflowManager from "@/components/WorkflowManager"; import CollapsibleSection from "@/components/ui/collapsible-section"; -import ModelSelector from "./ModelSelector"; +import { ModelSelectorPanel } from "@/modules/ai/images/components"; import { ImageFile } from "../types"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; + import { DropdownMenu, DropdownMenuContent, @@ -24,12 +22,16 @@ interface WizardSidebarProps { // Model selectedModel: string; onModelChange: (model: string) => void; + onLoadModel?: (userId: string) => Promise<string | null>; + onSaveModel?: (userId: string, model: string) => Promise<void>; aspectRatio: string; onAspectRatioChange: (ratio: string) => void; resolution: string; onResolutionChange: (res: string) => void; searchGrounding: boolean; onSearchGroundingChange: (enabled: boolean) => void; + imageSearch: boolean; + onImageSearchChange: (enabled: boolean) => void; // Presets selectedPreset: PromptPreset | null; @@ -83,12 +85,16 @@ interface WizardSidebarProps { export const WizardSidebar: React.FC<WizardSidebarProps> = ({ selectedModel, onModelChange, + onLoadModel, + onSaveModel, aspectRatio, onAspectRatioChange, resolution, onResolutionChange, searchGrounding, onSearchGroundingChange, + imageSearch, + onImageSearchChange, selectedPreset, presets, loadingPresets, @@ -132,60 +138,37 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({ <div className="lg:col-span-1 space-y-3 md:space-y-6"> <Card> <CardContent className="p-3 md:p-6 space-y-3 md:space-y-4"> - {/* Step 1: Model Selector */} - <ModelSelector - selectedModel={selectedModel} - onChange={onModelChange} - /> - - {/* Gemini 3 Pro Advanced Options */} - {selectedModel === 'google/gemini-3-pro-image-preview' && ( - <div className="grid grid-cols-2 gap-3 pt-2"> - <div> - <label className="text-xs font-medium text-muted-foreground"><T>Aspect Ratio</T></label> - <Select value={aspectRatio} onValueChange={onAspectRatioChange}> - <SelectTrigger className="h-9 text-xs"> - <SelectValue placeholder="Aspect Ratio" /> - </SelectTrigger> - <SelectContent> - {["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"].map(ratio => ( - <SelectItem key={ratio} value={ratio} className="text-xs">{ratio}</SelectItem> - ))} - </SelectContent> - </Select> + {/* Step 1: Model Selector (Collapsible) */} + <CollapsibleSection + title={ + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">1.</span> + <T>AI Model</T> </div> - <div> - <label className="text-xs font-medium text-muted-foreground"><T>Resolution</T></label> - <Select value={resolution} onValueChange={onResolutionChange}> - <SelectTrigger className="h-9 text-xs"> - <SelectValue placeholder="Resolution" /> - </SelectTrigger> - <SelectContent> - {["1K", "2K", "4K"].map(res => ( - <SelectItem key={res} value={res} className="text-xs">{res}</SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - )} - - {/* Google Search Grounding */} - {selectedModel.startsWith('google/') && ( - <div className="flex items-center justify-between pt-3"> - <Label htmlFor="search-grounding" className="flex flex-col space-y-1"> - <span className="font-medium text-sm"><T>Grounding with Google Search</T></span> - <span className="text-xs text-muted-foreground"> - <T>Use Google Search to improve image relevance and accuracy.</T> - </span> - </Label> - <Switch - id="search-grounding" - checked={searchGrounding} - onCheckedChange={onSearchGroundingChange} - /> - </div> - )} + } + initiallyOpen={true} + storageKey="wizard-model-section" + minimal={false} + titleClassName="text-sm font-medium" + buttonClassName="h-6 w-6 p-0" + contentClassName="p-2" + className="mb-3" + > + <ModelSelectorPanel + value={selectedModel} + onChange={onModelChange} + onLoadModel={onLoadModel} + onSaveModel={onSaveModel} + aspectRatio={aspectRatio} + onAspectRatioChange={onAspectRatioChange} + resolution={resolution} + onResolutionChange={onResolutionChange} + searchGrounding={searchGrounding} + onSearchGroundingChange={onSearchGroundingChange} + imageSearch={imageSearch} + onImageSearchChange={onImageSearchChange} + /> + </CollapsibleSection> {/* Step 2: Prompt Presets Manager */} <div className="hidden md:block"> @@ -215,10 +198,10 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({ } initiallyOpen={false} storageKey="wizard-presets-section" - minimal={true} + minimal={false} titleClassName="text-sm font-medium" buttonClassName="h-6 w-6 p-0" - contentClassName="pt-2" + contentClassName="p-2" className="mb-3" > {selectedPreset && ( @@ -258,10 +241,10 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({ } initiallyOpen={false} storageKey="wizard-workflows-section" - minimal={true} + minimal={false} titleClassName="text-sm font-medium" buttonClassName="h-6 w-6 p-0" - contentClassName="pt-2" + contentClassName="p-2" className="mb-3" > <WorkflowManager diff --git a/packages/ui/src/components/ImageWizard/components/index.ts b/packages/ui/src/components/ImageWizard/components/index.ts index d7dce05f..1bdb8e59 100644 --- a/packages/ui/src/components/ImageWizard/components/index.ts +++ b/packages/ui/src/components/ImageWizard/components/index.ts @@ -2,10 +2,10 @@ export { QuickActionsToolbar } from './QuickActionsToolbar'; export { ImageActionButtons } from './ImageActionButtons'; export { ModelSelector } from './ModelSelector'; +export { ModelSelectorPanel } from './ModelSelectorPanel'; export { default as WizardSidebar } from './WizardSidebar'; export { default as Prompt } from './Prompt'; export { default as ImageGalleryPanel } from './ImageGalleryPanel'; -export { PostComposer } from './PostComposer'; diff --git a/packages/ui/src/components/ImageWizard/db.ts b/packages/ui/src/components/ImageWizard/db.ts index bba300df..a6cfe19c 100644 --- a/packages/ui/src/components/ImageWizard/db.ts +++ b/packages/ui/src/components/ImageWizard/db.ts @@ -13,10 +13,59 @@ import { addCollectionPictures, } from "@/modules/posts/client-pictures"; import { getUserOpenAIKey } from "@/modules/user/client-user"; +import { supabase } from "@/integrations/supabase/client"; // Re-export for backward compat export { getUserOpenAIKey }; +/** + * Load saved wizard model from user_secrets.settings.wizard_model + */ +export const loadWizardModel = async (userId: string): Promise<string | null> => { + try { + const { data, error } = await supabase + .from('user_secrets') + .select('settings') + .eq('user_id', userId) + .maybeSingle(); + + if (error) throw error; + const settings = data?.settings as any; + return settings?.wizard_model || null; + } catch (error) { + console.error('Error loading wizard model:', error); + return null; + } +}; + +/** + * Save selected model to user_secrets.settings.wizard_model + */ +export const saveWizardModel = async (userId: string, model: string): Promise<void> => { + try { + const { data: existing } = await supabase + .from('user_secrets') + .select('settings') + .eq('user_id', userId) + .maybeSingle(); + + if (existing) { + const currentSettings = (existing.settings as Record<string, any>) || {}; + const newSettings = { ...currentSettings, wizard_model: model }; + + const { error } = await supabase + .from('user_secrets') + .update({ settings: newSettings }) + .eq('user_id', userId); + + if (error) throw error; + } + // If no user_secrets row exists, skip silently (will be created when user saves API keys) + } catch (error) { + console.error('Error saving wizard model:', error); + } +}; + /** * Upload image blob to storage */ @@ -163,19 +212,16 @@ export const publishImageAsVersion = async (params: { const uploadResult = await uploadImageToStorage(userId, blob, 'version'); if (!uploadResult) throw new Error('Failed to upload image'); - // Unselect all images in the family first const rootParentId = parentId && parentId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) ? parentId : null; - if (rootParentId) { - await unselectImageFamily(rootParentId, userId); - } - // Create version record (selected by default) + + // Create version record (not auto-selected to avoid unique constraint violation) const pictureData = await createPictureRecord({ userId, title, description: description || null, imageUrl: uploadResult.publicUrl, parentId: rootParentId, - isSelected: true, + isSelected: false, }); if (!pictureData) throw new Error('Failed to create version record'); diff --git a/packages/ui/src/components/ImageWizard/handlers/generationHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/generationHandlers.ts index df92355d..43ff4584 100644 --- a/packages/ui/src/components/ImageWizard/handlers/generationHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/generationHandlers.ts @@ -1,9 +1,7 @@ import { ImageFile } from '../types'; -import { supabase } from '@/integrations/supabase/client'; import { toast } from 'sonner'; import { translate } from '@/i18n'; -import { createImage, editImage } from '@/lib/image-router'; -import { runTools, optimizePrompt } from '@/lib/openai'; +import { optimizePrompt } from '@/lib/openai'; import { PromptPreset } from '@/components/PresetManager'; import { Logger } from '../utils/logger'; @@ -32,7 +30,7 @@ export const handleOptimizePrompt = async ( try { toast.info(translate('Optimizing your prompt...')); const optimized = await optimizePrompt(currentPrompt); - + if (optimized) { // Update the appropriate prompt field if (lightboxOpen) { @@ -76,9 +74,9 @@ export const abortGeneration = ( setIsGenerating(false); setIsAgentMode(false); // Remove any placeholder images when aborting - setImages(prev => prev.filter(img => - !img.title.includes('Generating') && - !img.title.includes('Agent working') && + setImages(prev => prev.filter(img => + !img.title.includes('Generating') && + !img.title.includes('Agent working') && !img.title.includes('Voice Agent working') )); toast.info('Generation aborted'); diff --git a/packages/ui/src/components/ImageWizard/hooks/useImageWizardState.ts b/packages/ui/src/components/ImageWizard/hooks/useImageWizardState.ts index 347a39dd..b7d9f85b 100644 --- a/packages/ui/src/components/ImageWizard/hooks/useImageWizardState.ts +++ b/packages/ui/src/components/ImageWizard/hooks/useImageWizardState.ts @@ -62,6 +62,8 @@ export interface ImageWizardState { setResolution: React.Dispatch<React.SetStateAction<string>>; searchGrounding: boolean; setSearchGrounding: React.Dispatch<React.SetStateAction<boolean>>; + imageSearch: boolean; + setImageSearch: React.Dispatch<React.SetStateAction<boolean>>; // Dialog State showDeleteConfirmDialog: boolean; @@ -167,6 +169,7 @@ export const useImageWizardState = ( const [aspectRatio, setAspectRatio] = useState<string>("1:1"); const [resolution, setResolution] = useState<string>("1K"); const [searchGrounding, setSearchGrounding] = useState<boolean>(false); + const [imageSearch, setImageSearch] = useState<boolean>(false); // Dialog State const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false); @@ -253,6 +256,8 @@ export const useImageWizardState = ( setResolution, searchGrounding, setSearchGrounding, + imageSearch, + setImageSearch, // Dialog State showDeleteConfirmDialog, diff --git a/packages/ui/src/components/ImageWizard/types.ts b/packages/ui/src/components/ImageWizard/types.ts index 80a58115..276eb3a2 100644 --- a/packages/ui/src/components/ImageWizard/types.ts +++ b/packages/ui/src/components/ImageWizard/types.ts @@ -34,6 +34,7 @@ export interface ImageWizardProps { mode?: 'default' | 'post'; // Add mode prop initialPostTitle?: string; initialPostDescription?: string; + initialPostSettings?: any; editingPostId?: string; } diff --git a/packages/ui/src/components/LightboxText.tsx b/packages/ui/src/components/LightboxText.tsx deleted file mode 100644 index 4ebbcc48..00000000 --- a/packages/ui/src/components/LightboxText.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { X, Copy, Check, Download, FileCode, FileText } from 'lucide-react'; - -interface LightboxTextProps { - isOpen: boolean; - onClose: () => void; - /** URL to fetch text content from */ - url: string; - /** File name for display and language detection */ - fileName: string; -} - -// Extension โ†’ language label for display -const EXT_LANG: Record<string, string> = { - ts: 'TypeScript', tsx: 'TypeScript (JSX)', js: 'JavaScript', jsx: 'JavaScript (JSX)', - py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', - c: 'C', cpp: 'C++', h: 'C Header', hpp: 'C++ Header', cs: 'C#', - swift: 'Swift', kt: 'Kotlin', lua: 'Lua', php: 'PHP', r: 'R', - sh: 'Shell', bash: 'Bash', zsh: 'Zsh', ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch', - sql: 'SQL', html: 'HTML', htm: 'HTML', css: 'CSS', - scss: 'SCSS', sass: 'Sass', less: 'Less', - json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', xml: 'XML', - vue: 'Vue', svelte: 'Svelte', - md: 'Markdown', txt: 'Text', log: 'Log', csv: 'CSV', tsv: 'TSV', - tex: 'LaTeX', ini: 'INI', cfg: 'Config', conf: 'Config', - dockerfile: 'Dockerfile', makefile: 'Makefile', -}; - -function getLanguage(fileName: string): string { - const lower = fileName.toLowerCase(); - // Handle extensionless names - if (lower === 'dockerfile') return 'Dockerfile'; - if (lower === 'makefile') return 'Makefile'; - const dot = lower.lastIndexOf('.'); - if (dot < 0) return 'Text'; - const ext = lower.slice(dot + 1); - return EXT_LANG[ext] || 'Text'; -} - -export default function LightboxText({ isOpen, onClose, url, fileName }: LightboxTextProps) { - const [content, setContent] = useState<string | null>(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - const [copied, setCopied] = useState(false); - - useEffect(() => { - if (!isOpen || !url) return; - setLoading(true); - setError(null); - setContent(null); - fetch(url) - .then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.text(); - }) - .then(setContent) - .catch(e => setError(e.message)) - .finally(() => setLoading(false)); - }, [isOpen, url]); - - useEffect(() => { - if (!isOpen) return; - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [isOpen, onClose]); - - const handleCopy = async () => { - if (!content) return; - await navigator.clipboard.writeText(content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const lineCount = content ? content.split('\n').length : 0; - const language = getLanguage(fileName); - const isCode = language !== 'Text' && language !== 'Log'; - - if (!isOpen) return null; - - return createPortal( - <div - onClick={onClose} - style={{ - position: 'fixed', inset: 0, zIndex: 99998, - background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)', - display: 'flex', alignItems: 'center', justifyContent: 'center', - padding: 20, - }} - > - <div - onClick={e => e.stopPropagation()} - style={{ - background: '#1e1e2e', color: '#cdd6f4', borderRadius: 10, - width: '90vw', maxWidth: 900, maxHeight: '90vh', - display: 'flex', flexDirection: 'column', - boxShadow: '0 16px 48px rgba(0,0,0,0.5)', - border: '1px solid #313244', - overflow: 'hidden', - }} - > - {/* Header */} - <div style={{ - display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', - borderBottom: '1px solid #313244', background: '#181825', - flexShrink: 0, - }}> - {isCode ? <FileCode size={16} style={{ color: '#89b4fa' }} /> : <FileText size={16} style={{ color: '#a6adc8' }} />} - <span style={{ fontWeight: 600, fontSize: 14, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> - {fileName} - </span> - <span style={{ fontSize: 11, color: '#6c7086', flexShrink: 0 }}> - {language}{lineCount > 0 ? ` ยท ${lineCount} lines` : ''} - </span> - <button - onClick={handleCopy} - title="Copy to clipboard" - style={{ - background: 'none', border: 'none', cursor: 'pointer', - color: copied ? '#a6e3a1' : '#6c7086', padding: 4, borderRadius: 4, - display: 'flex', alignItems: 'center', - }} - > - {copied ? <Check size={16} /> : <Copy size={16} />} - </button> - <a - href={url} - download={fileName} - title="Download" - style={{ - color: '#6c7086', padding: 4, borderRadius: 4, - display: 'flex', alignItems: 'center', - }} - > - <Download size={16} /> - </a> - <button - onClick={onClose} - title="Close (Esc)" - style={{ - background: 'none', border: 'none', cursor: 'pointer', - color: '#6c7086', padding: 4, borderRadius: 4, - display: 'flex', alignItems: 'center', - }} - > - <X size={18} /> - </button> - </div> - - {/* Content */} - <div style={{ flex: 1, overflow: 'auto', position: 'relative' }}> - {loading && ( - <div style={{ padding: 40, textAlign: 'center', color: '#6c7086' }}> - Loadingโ€ฆ - </div> - )} - {error && ( - <div style={{ padding: 40, textAlign: 'center', color: '#f38ba8' }}> - Failed to load: {error} - </div> - )} - {content !== null && ( - <div style={{ display: 'flex', fontSize: 13, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", lineHeight: 1.6 }}> - {/* Line numbers */} - <div style={{ - padding: '12px 0', textAlign: 'right', userSelect: 'none', - color: '#45475a', borderRight: '1px solid #313244', - flexShrink: 0, minWidth: 48, background: '#181825', - position: 'sticky', left: 0, - }}> - {content.split('\n').map((_, i) => ( - <div key={i} style={{ padding: '0 12px' }}>{i + 1}</div> - ))} - </div> - {/* Code */} - <pre style={{ - flex: 1, margin: 0, padding: 12, overflow: 'auto', - whiteSpace: 'pre', tabSize: 4, - }}> - {content} - </pre> - </div> - )} - </div> - </div> - </div>, - document.body - ); -} diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index 87faab78..cd567a10 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -8,15 +8,20 @@ import { formatDistanceToNow } from "date-fns"; import { MessageCircle, Heart, ExternalLink } from "lucide-react"; import UserAvatarBlock from "@/components/UserAvatarBlock"; import { Button } from "@/components/ui/button"; -const Post = React.lazy(() => import("@/pages/Post")); +import { T } from "@/i18n"; +const Post = React.lazy(() => import("@/modules/posts/PostPage")); const UserPage = React.lazy(() => import("@/modules/pages/UserPage")); interface ListLayoutProps { sortBy?: FeedSortOption; - navigationSource?: 'home' | 'collection' | 'tag' | 'user'; + navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'search'; navigationSourceId?: string; isOwner?: boolean; // Not strictly used for rendering list but good for consistency categorySlugs?: string[]; + categoryIds?: string[]; + contentType?: 'posts' | 'pages' | 'pictures' | 'files'; + visibilityFilter?: 'invisible' | 'private'; + center?: boolean; } const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolean, onClick: () => void }) => { @@ -30,7 +35,7 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea > <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 mb-1"> - <h3 className="text-sm font-semibold truncate text-foreground"> + <h3 className="text-sm font-semibold text-foreground"> {item.title || "Untitled"} </h3> {domain && ( @@ -41,7 +46,7 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea )} </div> - <div className="text-xs text-muted-foreground line-clamp-2 mb-2"> + <div className="text-xs text-muted-foreground line-clamp-3 mb-2"> {item.description} </div> @@ -60,15 +65,18 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea <span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span> <div className="flex items-center gap-3 ml-auto"> - <span className="flex items-center gap-1"> - <Heart className="h-3 w-3" /> - {item.likes_count || 0} - </span> - <span className="flex items-center gap-1"> - <MessageCircle className="h-3 w-3" /> - {/* Assuming comments count is available, otherwise 0 */} - {(item.comments && item.comments[0]?.count) || 0} - </span> + {(item.likes_count || 0) >= 10 && ( + <span className="flex items-center gap-1"> + <Heart className="h-3 w-3" /> + {item.likes_count} + </span> + )} + {((item.comments && item.comments[0]?.count) || 0) >= 10 && ( + <span className="flex items-center gap-1"> + <MessageCircle className="h-3 w-3" /> + {(item.comments && item.comments[0]?.count) || 0} + </span> + )} </div> </div> </div> @@ -92,11 +100,27 @@ const ListItem = ({ item, isSelected, onClick }: { item: any, isSelected: boolea ); }; +const getSearchGroup = (post: any): string => { + if (post.type === 'page-vfs-folder') return 'Folders'; + if (post._searchSource === 'picture') return 'Pictures'; + if (post._searchSource === 'file') { + if (post.thumbnail_url || post.cover || (post.pictures && post.pictures.length > 0)) return 'Pictures'; + return 'Files'; + } + if (post._searchSource === 'page') return 'Pages'; + if (post._searchSource === 'post') return 'Posts'; + return 'Posts'; +}; + export const ListLayout = ({ sortBy = 'latest', navigationSource = 'home', navigationSourceId, - categorySlugs + categorySlugs, + categoryIds, + contentType, + visibilityFilter, + center }: ListLayoutProps) => { const navigate = useNavigate(); const isMobile = useIsMobile(); @@ -112,10 +136,18 @@ export const ListLayout = ({ source: navigationSource, sourceId: navigationSourceId, sortBy, - categorySlugs + categorySlugs, + categoryIds, + contentType, + visibilityFilter }); const handleItemClick = (item: any) => { + if (item.meta?.url) { + navigate(item.meta.url); + return; + } + if (isMobile) { const slug = item.meta?.slug || item.cover?.meta?.slug || item.pictures?.[0]?.meta?.slug; if (item.type === 'page-intern' && slug) { @@ -150,7 +182,7 @@ export const ListLayout = ({ setSelectedId((feedPosts[newIndex] as any).id); // Ensure the list item is visible const element = document.getElementById(`list-item-${(feedPosts[newIndex] as any).id}`); - element?.scrollIntoView({ block: 'nearest' }); + // element?.scrollIntoView({ block: 'nearest' }); } } }; @@ -179,22 +211,63 @@ export const ListLayout = ({ return <div className="p-8 text-center text-muted-foreground">No posts found.</div>; } + const renderItems = (isMobileView: boolean) => { + const shouldGroup = navigationSource === 'search' && (!contentType || contentType === 'files'); + + if (!shouldGroup) { + return feedPosts.map((post: any) => ( + <div key={post.id} id={!isMobileView ? `list-item-${post.id}` : undefined}> + <ListItem + item={post} + isSelected={!isMobileView && selectedId === post.id} + onClick={() => handleItemClick(post)} + /> + </div> + )); + } + + const groups = new Map<string, any[]>(); + for (const post of feedPosts) { + const group = getSearchGroup(post); + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(post); + } + + const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files']; + const elements: React.ReactNode[] = []; + + for (const group of orderedGroups) { + if (groups.has(group)) { + elements.push( + <div key={`group-${group}`} className="px-3 py-1.5 bg-muted/40 border-y text-xs font-semibold uppercase tracking-wider text-muted-foreground sticky top-0 z-10 backdrop-blur-md"> + <T>{group}</T> + </div> + ); + elements.push( + ...groups.get(group)!.map((post: any) => ( + <div key={post.id} id={!isMobileView ? `list-item-${post.id}` : undefined}> + <ListItem + item={post} + isSelected={!isMobileView && selectedId === post.id} + onClick={() => handleItemClick(post)} + /> + </div> + )) + ); + } + } + + return elements; + }; + if (!isMobile) { // Desktop Split Layout return ( - <div className="flex h-[calc(100vh-8rem)] overflow-hidden border rounded-lg bg-background shadow-sm"> + <div className={`flex h-full overflow-hidden border rounded-lg shadow-sm dark:bg-slate-900/10 ${center ? 'max-w-7xl mx-auto' : ''}`}> {/* Left: List */} <div className="w-[350px] lg:w-[400px] border-r flex flex-col bg-card shrink-0"> - <div className="flex-1 overflow-y-auto scrollbar-custom"> - {feedPosts.map((post: any) => ( - <div key={post.id} id={`list-item-${post.id}`}> - <ListItem - item={post} - isSelected={selectedId === post.id} - onClick={() => handleItemClick(post)} - /> - </div> - ))} + <div className="flex-1 overflow-y-auto scrollbar-custom relative"> + {renderItems(false)} {hasMore && ( <div className="p-4 text-center"> <Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button> @@ -255,15 +328,8 @@ export const ListLayout = ({ // Mobile Layout return ( - <div className="flex flex-col"> - {feedPosts.map((post: any) => ( - <ListItem - key={post.id} - item={post} - isSelected={false} - onClick={() => handleItemClick(post)} - /> - ))} + <div className={`flex flex-col ${center ? 'max-w-3xl mx-auto' : ''}`}> + {renderItems(true)} {hasMore && ( <div className="p-4 text-center"> <Button variant="ghost" size="sm" onClick={() => loadMore()}>Load More</Button> diff --git a/packages/ui/src/components/MarkdownEditor.tsx b/packages/ui/src/components/MarkdownEditor.tsx index 01568b9d..91b9c59b 100644 --- a/packages/ui/src/components/MarkdownEditor.tsx +++ b/packages/ui/src/components/MarkdownEditor.tsx @@ -78,7 +78,7 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ </div> {activeTab === 'editor' && ( <React.Suspense fallback={<div className="p-3 text-muted-foreground">Loading editor...</div>}> - <div>Remove MilkdownEditorInternal</div> + <div></div> </React.Suspense> )} {activeTab === 'raw' && ( diff --git a/packages/ui/src/components/MarkdownEditorEx.tsx b/packages/ui/src/components/MarkdownEditorEx.tsx index eb986e14..82d9f8c4 100644 --- a/packages/ui/src/components/MarkdownEditorEx.tsx +++ b/packages/ui/src/components/MarkdownEditorEx.tsx @@ -1,11 +1,69 @@ import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { htmlTableToMarkdown } from '@/lib/htmlTableToMarkdown'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; import { fetchPictureById } from '@/modules/posts/client-pictures'; +import { toast } from 'sonner'; +import { translate } from '@/i18n'; // Lazy load the heavy editor component const MDXEditorInternal = React.lazy(() => import('@/components/lazy-editors/MDXEditorInternal')); +// โ”€โ”€ Error Boundary for MDXEditor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface ErrorBoundaryProps { + children: React.ReactNode; + value: string; + onChange: (v: string) => void; +} +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class MDXEditorErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { + state: ErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error) { + console.error('[MDXEditor] Parse error caught by boundary:', error); + } + + render() { + if (this.state.hasError) { + return ( + <div className="p-4 space-y-3"> + <div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/30 text-destructive text-sm"> + <svg className="h-4 w-4 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor"> + <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm-.75 3.75a.75.75 0 0 1 1.5 0v4a.75.75 0 0 1-1.5 0v-4zM8 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" /> + </svg> + <span> + The editor could not parse this content (likely contains invalid characters like bare <code><</code>). + Edit below or switch to the <strong>Markdown</strong> tab. + </span> + </div> + <textarea + value={this.props.value || ''} + onChange={(e) => this.props.onChange(e.target.value)} + className="w-full min-h-[200px] bg-transparent border rounded-md p-3 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none resize-y" + aria-label="Raw markdown (fallback)" + /> + <button + onClick={() => this.setState({ hasError: false, error: null })} + className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" + > + Retry editor + </button> + </div> + ); + } + return this.props.children; + } +} + interface MarkdownEditorProps { value: string; onChange: (value: string) => void; @@ -33,7 +91,9 @@ interface MDXEditorWithImagePickerProps { export function MDXEditorWithImagePicker(props: MDXEditorWithImagePickerProps) { return ( <React.Suspense fallback={<div className="p-4 text-center text-muted-foreground">Loading editor...</div>}> - <MDXEditorInternal {...props} /> + <MDXEditorErrorBoundary value={props.value} onChange={props.onChange}> + <MDXEditorInternal {...props} /> + </MDXEditorErrorBoundary> </React.Suspense> ); } @@ -67,6 +127,10 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ editorRef.current.setMarkdown(value); } catch (error) { console.warn('Failed to update MDXEditor:', error); + toast.warning( + translate('Content may contain characters that the editor cannot display. Switch to the Markdown tab to review.'), + { duration: 5000 }, + ); } } } @@ -110,9 +174,12 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ }, []); const handleImageSelect = useCallback(async (pictureId: string) => { + // Immediately clear reject ref so onClose (which fires right after) won't reject the promise + imageRejectRef.current = null; try { - // Fetch the image URL via API + console.log('[ImagePicker] Selected pictureId:', pictureId); const data = await fetchPictureById(pictureId); + console.log('[ImagePicker] Fetched data:', data); if (!data) throw new Error('Picture not found'); const imageUrl = data.image_url; @@ -121,14 +188,14 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ if (imageResolveRef.current) { imageResolveRef.current(imageUrl); imageResolveRef.current = null; - imageRejectRef.current = null; } setImagePickerOpen(false); } catch (error) { - if (imageRejectRef.current) { - imageRejectRef.current(); - imageRejectRef.current = null; + console.error('[ImagePicker] Error selecting image:', error); + // Resolve with empty to avoid unhandled rejection + if (imageResolveRef.current) { + imageResolveRef.current(''); imageResolveRef.current = null; } setImagePickerOpen(false); @@ -141,9 +208,35 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({ onChange(newValue); }, [onChange]); + // โ”€โ”€ Clipboard: intercept HTML table paste โ†’ markdown table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const handlePasteCapture = useCallback((e: React.ClipboardEvent) => { + console.log('[paste] capture fired', e.clipboardData.types); + const html = e.clipboardData?.getData('text/html'); + if (!html) return; + + const md = htmlTableToMarkdown(html); + if (!md) return; // no <table> โ†’ fall through to default + + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + + // MDXEditor rich tab: use ref API + if (editorRef.current?.insertMarkdown) { + editorRef.current.insertMarkdown(md); + } else { + // Raw textarea fallback: append at cursor / end + const newValue = lastEmittedValue.current + ? `${lastEmittedValue.current}\n\n${md}` + : md; + lastEmittedValue.current = newValue; + onChange(newValue); + } + }, [editorRef, onChange]); + return ( <> - <div className={`flex flex-col bg-background ${className} ${isFullscreen ? '' : 'border rounded-md'}`}> + <div onPasteCapture={handlePasteCapture} className={`flex flex-col bg-background ${className} ${isFullscreen ? '' : 'border rounded-md'}`}> <div className="flex border-b flex-shrink-0"> <button type="button" diff --git a/packages/ui/src/components/MarkdownRenderer.tsx b/packages/ui/src/components/MarkdownRenderer.tsx index 865aed88..50d2a088 100644 --- a/packages/ui/src/components/MarkdownRenderer.tsx +++ b/packages/ui/src/components/MarkdownRenderer.tsx @@ -1,12 +1,13 @@ -import React, { useMemo, useEffect, useRef, useState, Suspense } from 'react'; +import React, { useMemo, useEffect, useRef, useState, Suspense, useCallback } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; import HashtagText from './HashtagText'; import Prism from 'prismjs'; import ResponsiveImage from './ResponsiveImage'; import { useAuth } from '@/hooks/useAuth'; -// Import type from Post page (assuming relative path from src/components to src/pages/Post/types.ts) -import { PostMediaItem } from '../pages/Post/types'; +// Import type from Post module +import { PostMediaItem } from '@/modules/posts/views/types'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-javascript'; @@ -17,12 +18,16 @@ import 'prismjs/components/prism-markup'; import '../styles/prism-custom-theme.css'; // Lazy load SmartLightbox to avoid circular deps or heavy bundle on initial load -const SmartLightbox = React.lazy(() => import('../pages/Post/components/SmartLightbox')); +const SmartLightbox = React.lazy(() => import('@/modules/posts/views/components/SmartLightbox')); +const GalleryWidget = React.lazy(() => import('./widgets/GalleryWidget')); +const MermaidWidget = React.lazy(() => import('./widgets/MermaidWidget')); interface MarkdownRendererProps { content: string; className?: string; variables?: Record<string, any>; + baseUrl?: string; + onLinkClick?: (href: string, e: React.MouseEvent<HTMLAnchorElement>) => void; } // Helper function to format URL display text (ported from previous implementation) @@ -63,16 +68,62 @@ const slugify = (text: string) => { .replace(/^-+|-+$/g, ''); }; +/** Recursively extract plain text from React children (handles nested <a>, <strong>, etc.) */ +const getPlainText = (children: React.ReactNode): string => { + if (typeof children === 'string' || typeof children === 'number') return String(children); + if (Array.isArray(children)) return children.map(getPlainText).join(''); + if (React.isValidElement(children)) return getPlainText((children.props as any).children); + return ''; +}; + import { substitute } from '@/lib/variables'; -const MarkdownRenderer = React.memo(({ content, className = "", variables }: MarkdownRendererProps) => { +// Helper to strip YAML frontmatter +const stripFrontmatter = (text: string) => { + return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trimStart(); +}; + +const MarkdownRenderer = React.memo(({ content, className = "", variables, baseUrl, onLinkClick }: MarkdownRendererProps) => { const containerRef = React.useRef<HTMLDivElement>(null); const { user } = useAuth(); + // Helper to resolve relative URLs + const resolveUrl = useCallback((url: string | undefined) => { + if (!url) return ''; + if (!baseUrl) return url; + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url; + + // Resolve relative path against baseUrl + try { + // If baseUrl is relative, make it absolute using the API origin so the server can fetch it + let absoluteBase = baseUrl; + if (baseUrl.startsWith('/')) { + const apiOrigin = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; + // if API url is absolute (http://...), use it as the base. + // fallback to window.location.origin for relative API configs. + const originToUse = apiOrigin.startsWith('http') ? apiOrigin : window.location.origin; + // Avoid double-prefixing if baseUrl already contains the origin root (e.g. from SSR) + if (!baseUrl.startsWith(originToUse)) { + absoluteBase = `${originToUse}${baseUrl}`; + } + } + + // Ensure the base URL resolves to the directory, not the file + // URL constructor resolves './file' relative to the path. If path doesn't end in '/', + // it strips the last segment. So we DO NOT want to arbitrarily append '/' to a file path. + // If absoluteBase is a file path (e.g. '.../document.md'), the URL constructor natively handles: + // new URL('./image.jpg', '.../document.md') => '.../image.jpg' + return new URL(url, absoluteBase).href; + } catch { + return url; // Fallback if parsing fails + } + }, [baseUrl]); + // Substitute variables in content if provided const finalContent = useMemo(() => { - if (!variables || Object.keys(variables).length === 0) return content; - return substitute(false, content, variables); + const withoutFrontmatter = stripFrontmatter(content); + if (!variables || Object.keys(variables).length === 0) return withoutFrontmatter; + return substitute(false, withoutFrontmatter, variables); }, [content, variables]); // Lightbox state @@ -108,12 +159,8 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar }; }, [finalContent]); - // Apply syntax highlighting after render - useEffect(() => { - if (containerRef.current) { - Prism.highlightAllUnder(containerRef.current); - } - }, [finalContent]); + // Removed Prism.highlightAllUnder to prevent React NotFoundError during streaming + // Highlighting is now handled safely within the `code` component renderer. const handleImageClick = (src: string) => { const index = allImages.findIndex(img => img.src === src); @@ -135,12 +182,13 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar const mockMediaItem = useMemo((): PostMediaItem | null => { const selectedImage = allImages[currentImageIndex]; if (!selectedImage) return null; + const resolvedUrl = resolveUrl(selectedImage.src); return { - id: 'md-' + btoa(selectedImage.src).substring(0, 10), // stable ID based on SRC + id: 'md-' + btoa(encodeURIComponent(selectedImage.src)).substring(0, 10), // stable ID based on SRC title: selectedImage.alt || 'Image', description: '', - image_url: selectedImage.src, - thumbnail_url: selectedImage.src, + image_url: resolvedUrl, + thumbnail_url: resolvedUrl, user_id: user?.id || 'unknown', type: 'image', created_at: new Date().toISOString(), @@ -149,7 +197,7 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar likes_count: 0, post_id: null } as any; - }, [currentImageIndex, allImages, user]); + }, [currentImageIndex, allImages, user, resolveUrl]); // Only use HashtagText if content has hashtags but NO markdown syntax at all if (contentAnalysis.hasHashtags && !contentAnalysis.hasMarkdownLinks && !contentAnalysis.hasMarkdownSyntax) { @@ -168,13 +216,16 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar > <ReactMarkdown remarkPlugins={[remarkGfm]} + rehypePlugins={[rehypeRaw]} components={{ img: ({ node, src, alt, title, ...props }) => { // Basic implementation of ResponsiveImage + const resolvedSrc = resolveUrl(src); + return ( <span className="block my-4"> <ResponsiveImage - src={src || ''} + src={resolvedSrc} alt={alt || ''} title={title} // Pass title down if ResponsiveImage supports it or wrap it className={`cursor-pointer ${props.className || ''}`} @@ -182,7 +233,7 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar // Default generous sizes for blog post content sizes="(max-width: 768px) 100vw, 800px" loading="lazy" - onClick={() => src && handleImageClick(src)} + onClick={() => resolvedSrc && handleImageClick(src || '')} /> {title && <span className="block text-center text-sm text-muted-foreground mt-2 italic">{title}</span>} </span> @@ -203,12 +254,19 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar const isAutoLink = childText === href || childText.replace(/^https?:\/\//, '') === href.replace(/^https?:\/\//, ''); const displayContent = isAutoLink ? formatUrlDisplay(href) : children; + const isRelative = !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('mailto:') && !href.startsWith('tel:') && !href.startsWith('data:') && !href.startsWith('#'); + return ( <a href={href} - target="_blank" + target={isRelative ? undefined : "_blank"} rel="noopener noreferrer" className="text-primary hover:text-primary/80 underline hover:no-underline transition-colors" + onClick={(e) => { + if (onLinkClick) { + onLinkClick(href, e); + } + }} {...props} > {displayContent} @@ -216,22 +274,22 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar ); }, h1: ({ node, children, ...props }) => { - const text = String(children); + const text = getPlainText(children); const id = slugify(text); return <h1 id={id} {...props}>{children}</h1>; }, h2: ({ node, children, ...props }) => { - const text = String(children); + const text = getPlainText(children); const id = slugify(text); return <h2 id={id} {...props}>{children}</h2>; }, h3: ({ node, children, ...props }) => { - const text = String(children); + const text = getPlainText(children); const id = slugify(text); return <h3 id={id} {...props}>{children}</h3>; }, h4: ({ node, children, ...props }) => { - const text = String(children); + const text = getPlainText(children); const id = slugify(text); return <h4 id={id} {...props}>{children}</h4>; }, @@ -261,6 +319,86 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables }: Mar td: ({ node, ...props }) => ( <td className="border border-border px-3 py-2 text-sm" {...props} /> ), + // Custom component: ```custom-gallery\nid1,id2,id3\n``` + code: ({ node, className, children, ...props }) => { + if (className === 'language-mermaid') { + const chart = String(children).trim(); + return ( + <Suspense fallback={<div className="animate-pulse h-32 bg-muted/20 border border-border/50 rounded-lg flex items-center justify-center my-6 text-sm text-muted-foreground">Loading Mermaid diagram...</div>}> + <MermaidWidget chart={chart} /> + </Suspense> + ); + } + + if (className === 'language-custom-gallery') { + const ids = String(children).trim().split(/[,\s\n]+/).filter(Boolean); + if (ids.length > 0) { + return ( + <Suspense fallback={<div className="animate-pulse h-48 bg-muted rounded" />}> + <GalleryWidget pictureIds={ids} thumbnailLayout="grid" imageFit="cover" /> + </Suspense> + ); + } + } + + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : ''; + + if (!match) { + // Inline code or unclassified code + return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm bg-muted/30 px-1 py-0.5 rounded`} {...props}>{children}</code>; + } + + const text = String(children).replace(/\n$/, ''); + + // Handle common language aliases + let prismLang = language; + if (language === 'ts') prismLang = 'typescript'; + if (language === 'js') prismLang = 'javascript'; + if (language === 'sh') prismLang = 'bash'; + if (language === 'html' || language === 'xml') prismLang = 'markup'; + + if (Prism.languages[prismLang]) { + try { + const html = Prism.highlight(text, Prism.languages[prismLang], prismLang); + return ( + <code + className={`${className} whitespace-pre-wrap font-mono text-sm`} + dangerouslySetInnerHTML={{ __html: html }} + {...props} + // Avoid passing children when using dangerouslySetInnerHTML + children={undefined} + /> + ); + } catch (e) { + console.error('Prism highlight error', e); + } + } + + // Fallback to unhighlighted + return <code className={`${className || ''} whitespace-pre-wrap font-mono text-sm`} {...props}>{children}</code>; + }, + // Unwrap <pre> for custom components (gallery etc.) + pre: ({ node, children, ...props }) => { + // Check the actual AST node type to see if it's our custom gallery + const firstChild = node?.children?.[0]; + if (firstChild?.type === 'element' && firstChild?.tagName === 'code') { + const isGallery = Array.isArray(firstChild.properties?.className) + && firstChild.properties?.className.includes('language-custom-gallery'); + const isMermaid = Array.isArray(firstChild.properties?.className) + && firstChild.properties?.className.includes('language-mermaid'); + + if (isGallery || isMermaid) { + return <>{children}</>; + } + + // Normal code block + return <pre className={`${props.className || ''} whitespace-pre-wrap break-words overflow-x-auto p-4 rounded-lg bg-muted/50 border border-border/50 mt-4 mb-4`} {...props}>{children}</pre>; + } + + // Fallback + return <pre {...props}>{children}</pre>; + }, }} > {finalContent} diff --git a/packages/ui/src/components/MediaCard.tsx b/packages/ui/src/components/MediaCard.tsx index ba9613bd..a7140005 100644 --- a/packages/ui/src/components/MediaCard.tsx +++ b/packages/ui/src/components/MediaCard.tsx @@ -9,6 +9,8 @@ import VideoCard from '@/components/VideoCard'; import PageCard from '@/modules/pages/PageCard'; import type { CardPreset } from '@/modules/pages/PageCard'; import { normalizeMediaType, MEDIA_TYPES, type MediaType } from '@/lib/mediaRegistry'; +import { getMimeCategory, CATEGORY_STYLE } from '@/modules/storage/helpers'; +import type { INode } from '@/modules/storage/types'; interface MediaCardProps { id: string; @@ -81,7 +83,7 @@ const MediaCard: React.FC<MediaCardProps> = ({ ); } - if (normalizedType === MEDIA_TYPES.VIDEO_INTERN) { + if (normalizedType === MEDIA_TYPES.VIDEO_INTERN || normalizedType === MEDIA_TYPES.VIDEO_EXTERN) { return ( <VideoCard videoId={pictureId || id} @@ -106,7 +108,68 @@ const MediaCard: React.FC<MediaCardProps> = ({ /> ); } - if (normalizedType === MEDIA_TYPES.PAGE || normalizedType === MEDIA_TYPES.PAGE_EXTERNAL) { + if (normalizedType === 'page-vfs-file' || normalizedType === 'page-vfs-folder') { + // If we have a thumbnail_url mapped by the feed, that means client-posts resolved a valid cover. + // Let PageCard render it normally as an image. + if (!thumbnailUrl) { + const isFolder = normalizedType === 'page-vfs-folder'; + const mockNode: INode = { name: title, mime: isFolder ? 'inode/directory' : '', type: isFolder ? 'dir' : 'file', path: '', size: 0, parent: '' } as any; + const category = getMimeCategory(mockNode); + const style = CATEGORY_STYLE[category] || CATEGORY_STYLE.other; + const Icon = style.icon; + + if (variant === 'feed') { + return ( + <div + onClick={() => onClick?.(id)} + className="group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full mb-4 border rounded-lg hover:border-primary/50" + > + <div className="w-full aspect-video bg-muted/20 flex flex-col items-center justify-center gap-4"> + <Icon className="w-24 h-24" style={{ color: style.color }} /> + <span className="text-lg font-medium text-muted-foreground truncate max-w-[80%] px-4">{title}</span> + </div> + {showContent && ( + <div className="p-4 space-y-2 border-t"> + <div className="font-semibold text-base truncate flex items-center gap-2"> + <Icon className="w-5 h-5 flex-shrink-0" style={{ color: style.color }} /> + <span className="truncate" title={title}>{title}</span> + </div> + {description && <div className="text-sm text-muted-foreground truncate" title={description}>{description}</div>} + </div> + )} + </div> + ); + } + + return ( + <div + onClick={() => onClick?.(id)} + className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${preset?.showTitle ? '' : 'md:aspect-square'} flex flex-col border rounded-lg hover:border-primary/50`} + > + <div className="flex-1 w-full aspect-square md:aspect-auto flex items-center justify-center bg-muted/20 relative"> + <Icon className="w-16 h-16 transition-transform duration-300 group-hover:scale-110" style={{ color: style.color }} /> + </div> + {(preset?.showTitle !== false || preset?.showDescription !== false) && ( + <div className="px-3 py-2 border-t bg-muted/40 absolute bottom-0 left-0 right-0 md:relative bg-background/95 backdrop-blur-sm md:bg-muted/40 md:backdrop-blur-none transition-transform pointer-events-none"> + {preset?.showTitle !== false && title && ( + <h3 className="text-sm font-medium truncate flex items-center gap-1.5" title={title}> + <span className="truncate">{title}</span> + </h3> + )} + {preset?.showDescription !== false && description && ( + <p className="text-xs text-muted-foreground truncate mt-0.5">{description}</p> + )} + </div> + )} + </div> + ); + } + } + + if (normalizedType === MEDIA_TYPES.PAGE || + normalizedType === MEDIA_TYPES.PAGE_EXTERNAL || + normalizedType === 'page-vfs-file' || + normalizedType === 'page-vfs-folder') { return ( <PageCard id={id} diff --git a/packages/ui/src/components/PhotoCard.tsx b/packages/ui/src/components/PhotoCard.tsx index dd9384a3..f9ed912e 100644 --- a/packages/ui/src/components/PhotoCard.tsx +++ b/packages/ui/src/components/PhotoCard.tsx @@ -3,14 +3,14 @@ import type { CardPreset } from "@/modules/pages/PageCard"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import MarkdownRenderer from "@/components/MarkdownRenderer"; -import EditImageModal from "@/components/EditImageModal"; -import ImageLightbox from "@/components/ImageLightbox"; +const EditImageModal = React.lazy(() => import("@/components/EditImageModal")); +const ImageLightbox = React.lazy(() => import("@/components/ImageLightbox")); import MagicWizardButton from "@/components/MagicWizardButton"; import { downloadImage, generateFilename } from "@/utils/downloadUtils"; -import { editImage } from "@/image-api"; -import { usePostNavigation } from "@/contexts/PostNavigationContext"; + + import { useNavigate } from "react-router-dom"; import ResponsiveImage from "@/components/ResponsiveImage"; import { T, translate } from "@/i18n"; @@ -87,7 +87,7 @@ const PhotoCard = ({ }: PhotoCardProps) => { const { user } = useAuth(); const navigate = useNavigate(); - const { navigationData, setNavigationData, preloadImage } = usePostNavigation(); + const [localIsLiked, setLocalIsLiked] = useState(isLiked); const [localLikes, setLocalLikes] = useState(likes); const [showEditModal, setShowEditModal] = useState(false); @@ -171,55 +171,10 @@ const PhotoCard = ({ }; const handleLightboxOpen = () => { - // Update current index in navigation data - if (navigationData) { - const currentIndex = navigationData.posts.findIndex(p => p.id === pictureId); - if (currentIndex !== -1) { - setNavigationData({ - ...navigationData, - currentIndex - }); - } - } setShowLightbox(true); }; - const handleNavigate = (direction: 'prev' | 'next') => { - if (!navigationData || !navigationData.posts.length) { - toast.error(translate('No navigation data available')); - return; - } - const newIndex = direction === 'next' - ? navigationData.currentIndex + 1 - : navigationData.currentIndex - 1; - - if (newIndex >= 0 && newIndex < navigationData.posts.length) { - const newPost = navigationData.posts[newIndex]; - setNavigationData({ - ...navigationData, - currentIndex: newIndex - }); - - // Navigate to the new post - navigate(`/post/${newPost.id}`); - } else { - toast.info(translate(direction === 'next' ? 'No next post available' : 'No previous post available')); - } - }; - - const handlePreload = (direction: 'prev' | 'next') => { - if (!navigationData) return; - - const targetIndex = direction === 'next' - ? navigationData.currentIndex + 1 - : navigationData.currentIndex - 1; - - if (targetIndex >= 0 && targetIndex < navigationData.posts.length) { - const targetPost = navigationData.posts[targetIndex]; - preloadImage(targetPost.image_url); - } - }; const handlePromptSubmit = async (prompt: string) => { if (!prompt.trim()) { @@ -236,6 +191,7 @@ const PhotoCard = ({ type: blob.type || 'image/png' }); + const { editImage } = await import("@/image-api"); const result = await editImage(prompt, [file]); if (result) { @@ -332,19 +288,20 @@ const PhotoCard = ({ handleClick(e); } }; + // console.log(preset, variant); return ( <div data-testid="photo-card" - className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${className || ''}`} + className={`group relative overflow-hidden bg-card border rounded-lg transition-all duration-300 cursor-pointer w-full ${className || ''}`} onClick={handleCardClick} > {/* Image */} - <div className={`${variant === 'grid' && !className?.includes('h-full') ? "aspect-square" : ""} ${className?.includes('h-full') ? 'flex-1 min-h-0' : ''} overflow-hidden`}> + <div className={`${variant === 'grid' && !className?.includes('h-full') ? "aspect-square" : ""} ${variant === 'feed' && !className?.includes('h-full') ? "aspect-[16/9]" : ""} ${className?.includes('h-full') ? 'flex-1 min-h-0' : ''} overflow-hidden`}> <ResponsiveImage src={image} alt={title} className={`w-full h-full ${imageFit === 'contain' ? 'bg-black/5' : ''}`} - imgClassName={`w-full h-full object-${imageFit} transition-transform duration-300 ${imageFit === 'cover' ? 'group-hover:scale-105' : ''}`} + imgClassName={`w-full h-full object-${imageFit} transition-transform duration-300`} /* TESTING: group-hover:scale-105 disabled */ sizes={variant === 'grid' ? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw" : "100vw" @@ -362,20 +319,20 @@ const PhotoCard = ({ )} </div> - {/* Info bar below image (preset-driven, desktop only) */} - {variant === 'grid' && (preset?.showTitle || preset?.showDescription) && (title || description) && ( - <div className="hidden md:block px-2.5 py-2 border-t"> - {preset?.showTitle && title && !isLikelyFilename(title) && ( + + {variant === 'grid' && (title || description) && ( + <div className="px-2 py-1.5 border-t"> + {showTitle && title && !isLikelyFilename(title) && ( <h3 className="text-sm font-medium truncate">{title}</h3> )} - {preset?.showDescription && description && ( - <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{description}</p> + {showDescription && description && ( + <p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">{description}</p> )} </div> )} - {/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant */} - {showContent && variant === 'grid' && ( + {/* TESTING: Entire desktop hover overlay disabled */} + {false && showContent && variant === 'grid' && ( <div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}> <div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto"> <div className="flex items-center justify-between mb-2"> @@ -460,6 +417,7 @@ const PhotoCard = ({ <MarkdownRenderer content={description} className="prose-invert prose-white" /> </div> )} + {createdAt && ( <div className="text-white/60 text-xs mb-2"> {formatDate(createdAt)} @@ -649,13 +607,9 @@ const PhotoCard = ({ onPublish={handlePublish} isGenerating={isGenerating} isPublishing={isPublishing} - showPrompt={!isExternal} // Hide prompt/edit for external + showPrompt={!isExternal} showPublish={!!generatedImageUrl && !isExternal} generatedImageUrl={generatedImageUrl || undefined} - currentIndex={navigationData?.currentIndex} - totalCount={navigationData?.posts.length} - onNavigate={handleNavigate} - onPreload={handlePreload} /> </div> ); diff --git a/packages/ui/src/components/PhotoGrid.tsx b/packages/ui/src/components/PhotoGrid.tsx index f1c8eca8..d25e595e 100644 --- a/packages/ui/src/components/PhotoGrid.tsx +++ b/packages/ui/src/components/PhotoGrid.tsx @@ -1,9 +1,9 @@ -import { UserProfile } from '../pages/Post/types'; +import { UserProfile } from '@/modules/posts/views/types'; import MediaCard from "./MediaCard"; import React, { useEffect, useState, useRef } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useNavigate } from "react-router-dom"; -import { usePostNavigation } from "@/hooks/usePostNavigation"; + import { useOrganization } from "@/contexts/OrganizationContext"; import { useFeedCache } from "@/contexts/FeedCacheContext"; import { useLayoutEffect } from "react"; @@ -13,6 +13,7 @@ import { useFeedData } from "@/hooks/useFeedData"; import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry"; import { UploadCloud, Maximize, FolderTree } from "lucide-react"; import { toast } from "sonner"; +import { T } from "@/i18n"; import type { MediaType } from "@/types"; import type { CardPreset } from "@/modules/pages/PageCard"; @@ -34,6 +35,7 @@ export interface MediaItemType { job?: any; responsive?: any; // Add responsive data versionCount?: number; + _searchSource?: string; } import type { FeedSortOption } from '@/hooks/useFeedData'; @@ -44,7 +46,7 @@ import { fetchUserMediaLikes } from "@/modules/posts/client-pictures"; interface MediaGridProps { customPictures?: MediaItemType[]; customLoading?: boolean; - navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget'; + navigationSource?: 'home' | 'collection' | 'tag' | 'user' | 'widget' | 'search'; navigationSourceId?: string; isOwner?: boolean; onFilesDrop?: (files: File[]) => void; @@ -53,7 +55,12 @@ interface MediaGridProps { supabaseClient?: any; apiUrl?: string; categorySlugs?: string[]; + categoryIds?: string[]; preset?: CardPreset; + contentType?: 'posts' | 'pages' | 'pictures' | 'files'; + visibilityFilter?: 'invisible' | 'private'; + center?: boolean; + columns?: number | 'auto'; } const DEFAULT_PRESET: CardPreset = { showTitle: true, showDescription: true }; @@ -70,20 +77,24 @@ const MediaGrid = ({ supabaseClient, apiUrl, categorySlugs, - preset = DEFAULT_PRESET + categoryIds, + preset = DEFAULT_PRESET, + contentType, + visibilityFilter, + center, + columns = 'auto' }: MediaGridProps) => { const { user } = useAuth(); const navigate = useNavigate(); - const { setNavigationData, navigationData } = usePostNavigation(); - const { getCache, saveCache } = useFeedCache(); + const { orgSlug, isOrgContext } = useOrganization(); // State definitions restored + const { getCache, saveCache } = useFeedCache(); const [mediaItems, setMediaItems] = useState<MediaItemType[]>([]); const [userLikes, setUserLikes] = useState<Set<string>>(new Set()); - const [loading, setLoading] = useState(true); const [isDragging, setIsDragging] = useState(false); const dragLeaveTimeoutRef = useRef<number | null>(null); @@ -101,10 +112,17 @@ const MediaGrid = ({ orgSlug, sortBy, categorySlugs, + categoryIds, + contentType, + visibilityFilter, // Disable hook if we have custom pictures enabled: !customPictures, supabaseClient }); + + // Derive loading from hook/props instead of syncing via setState + const loading = customPictures ? (customLoading || false) : feedLoading; + // Infinite Scroll Observer const observerTarget = useRef(null); @@ -135,32 +153,13 @@ const MediaGrid = ({ if (customPictures) { finalMedia = customPictures; - setLoading(customLoading || false); } else { // Map FeedPost[] -> MediaItemType[] finalMedia = mapFeedPostsToMediaItems(feedPosts as any, sortBy); - setLoading(feedLoading); } setMediaItems(finalMedia); - - // Update Navigation Data - if (finalMedia.length > 0) { - const navData = { - posts: finalMedia.map(item => ({ - id: item.id, - title: item.title, - image_url: item.image_url, - user_id: item.user_id, - type: normalizeMediaType(item.type) - })), - currentIndex: 0, - source: navigationSource, - sourceId: navigationSourceId - }; - setNavigationData(navData); - } - }, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]); + }, [feedPosts, customPictures, sortBy]); // Scroll Restoration Logic const cacheKey = `${navigationSource}-${navigationSourceId || ''}-${isOrgContext ? 'org' : 'personal'}-${orgSlug || ''}`; @@ -247,21 +246,20 @@ const MediaGrid = ({ }; const handleMediaClick = (mediaId: string, type: MediaType, index: number) => { - console.log('handleMediaClick', mediaId, type, index); - // Handle Page navigation - if (type === 'page-intern') { - const item = mediaItems.find(i => i.id === mediaId); + const item = mediaItems.find(i => i.id === mediaId); + if (item?.meta?.url) { + navigate(item.meta.url); + return; + } + + if (type === 'page-intern') { if (item && item.meta?.slug) { navigate(`/user/${item.author?.username || item.user_id}/pages/${item.meta.slug}`); return; } } - // Update navigation data with current index for correct Prev/Next behavior - if (navigationData) { - setNavigationData({ ...navigationData, currentIndex: index }); - } navigate(`/post/${mediaId}`); }; @@ -347,32 +345,51 @@ const MediaGrid = ({ }; - // Set up navigation data when media items change (for custom media) - useEffect(() => { - if (mediaItems.length > 0 && customPictures) { - const navData = { - posts: mediaItems - .filter(item => !isVideoType(normalizeMediaType(item.type))) - .map(item => ({ - id: item.id, - title: item.title, - image_url: item.image_url, - user_id: item.user_id - })), - currentIndex: 0, - source: navigationSource, - sourceId: navigationSourceId - }; - setNavigationData(navData); - } - }, [mediaItems, customPictures, navigationSource, navigationSourceId, setNavigationData]); + // Group media items by category // - When filtering by category: group by immediate subcategories (show parent + each child) // - When on home feed: don't group (flat list) + const isSearchTabAllOrFiles = navigationSource === 'search' && (!contentType || contentType === 'files'); const shouldGroupByCategory = !!categorySlugs && categorySlugs.length > 0; + const getSearchGroup = (item: any): string => { + if (item.type === 'page-vfs-folder') return 'Folders'; + if (item._searchSource === 'picture') return 'Pictures'; + if (item._searchSource === 'file') { + if (item.thumbnail_url || item.cover || (item.pictures && item.pictures.length > 0)) return 'Pictures'; + return 'Files'; + } + if (item._searchSource === 'page') return 'Pages'; + if (item._searchSource === 'post') return 'Posts'; + return 'Posts'; + }; + const groupedItems = React.useMemo(() => { + if (isSearchTabAllOrFiles) { + const groups = new Map<string, MediaItemType[]>(); + mediaItems.forEach(item => { + const group = getSearchGroup(item); + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(item); + }); + + const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files']; + const sections = []; + for (const group of orderedGroups) { + if (groups.has(group)) { + sections.push({ + key: group, + title: group, + items: groups.get(group)! + }); + } + } + + if (sections.length > 0) return { sections }; + return { sections: [{ key: 'all', title: null, items: mediaItems }] }; + } + if (!shouldGroupByCategory) { return { sections: [{ key: 'all', title: null, items: mediaItems }] }; } @@ -453,7 +470,7 @@ const MediaGrid = ({ } return { sections }; - }, [mediaItems, feedPosts, shouldGroupByCategory, categorySlugs, navigationSource]); + }, [mediaItems, feedPosts, shouldGroupByCategory, categorySlugs, navigationSource, isSearchTabAllOrFiles]); if (loading) { return ( @@ -513,30 +530,75 @@ const MediaGrid = ({ {section.title && ( <h2 className="text-lg font-semibold mb-4 px-4 flex items-center gap-2 capitalize"> <FolderTree className="h-5 w-5" /> - {section.title} + <T>{section.title}</T> </h2> )} - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4"> - {section.items.map((item, index) => { - const itemType = normalizeMediaType(item.type); - const isVideo = isVideoType(itemType); - // For images, convert URL to optimized format - const displayUrl = item.image_url; - if (isVideo) { - return ( - <div key={item.id} className="relative group"> + {(() => { + const isAuto = columns === 'auto'; + const colNum = Number(columns); + const gridColsClass = isAuto ? 'grid-cols-[repeat(auto-fit,minmax(250px,380px))] justify-center' : + colNum === 1 ? 'grid-cols-1' : + colNum === 2 ? 'grid-cols-1 md:grid-cols-2' : + colNum === 3 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : + colNum === 5 ? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5' : + colNum === 6 ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6' : + 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'; + return ( + <div className={`grid ${gridColsClass} gap-4 px-4`}> + {section.items.map((item, index) => { + const itemType = normalizeMediaType(item.type); + const isVideo = isVideoType(itemType); + // For images, convert URL to optimized format + const displayUrl = item.image_url; + if (isVideo) { + return ( + <div key={item.id} className="relative group"> + <MediaCard + id={item.id} + pictureId={item.picture_id} + url={displayUrl} + thumbnailUrl={item.thumbnail_url} + title={item.title} + // Pass blank/undefined so UserAvatarBlock uses context data + author={undefined as any} + authorAvatarUrl={undefined} + authorId={item.user_id} + likes={item.likes_count || 0} + comments={item.comments[0]?.count || 0} + isLiked={userLikes.has(item.picture_id || item.id)} + description={item.description} + type={itemType} + meta={item.meta} + onClick={() => handleMediaClick(item.id, itemType, index)} + onLike={fetchUserLikes} + onDelete={fetchMediaFromPicturesTable} + onEdit={handleEditPost} + created_at={item.created_at} + job={item.job} + responsive={item.responsive} + apiUrl={apiUrl} + preset={preset} + /> + <div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 transition-opacity"> {/* TESTING: group-hover:opacity-100 disabled */} + <Maximize className="w-4 h-4 text-white" /> + </div> + </div> + ); + } + + return ( <MediaCard + key={item.id} id={item.id} pictureId={item.picture_id} url={displayUrl} thumbnailUrl={item.thumbnail_url} title={item.title} - // Pass blank/undefined so UserAvatarBlock uses context data author={undefined as any} authorAvatarUrl={undefined} authorId={item.user_id} likes={item.likes_count || 0} - comments={item.comments[0]?.count || 0} + comments={item?.comments?.[0]?.count || 0} isLiked={userLikes.has(item.picture_id || item.id)} description={item.description} type={itemType} @@ -545,50 +607,18 @@ const MediaGrid = ({ onLike={fetchUserLikes} onDelete={fetchMediaFromPicturesTable} onEdit={handleEditPost} + created_at={item.created_at} job={item.job} responsive={item.responsive} apiUrl={apiUrl} preset={preset} /> - <div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"> - <Maximize className="w-4 h-4 text-white" /> - </div> - </div> - ); - } - - return ( - <MediaCard - key={item.id} - id={item.id} - pictureId={item.picture_id} - url={displayUrl} - thumbnailUrl={item.thumbnail_url} - title={item.title} - author={undefined as any} - authorAvatarUrl={undefined} - authorId={item.user_id} - likes={item.likes_count || 0} - comments={item?.comments?.[0]?.count || 0} - isLiked={userLikes.has(item.picture_id || item.id)} - description={item.description} - type={itemType} - meta={item.meta} - onClick={() => handleMediaClick(item.id, itemType, index)} - onLike={fetchUserLikes} - onDelete={fetchMediaFromPicturesTable} - onEdit={handleEditPost} - - created_at={item.created_at} - job={item.job} - responsive={item.responsive} - apiUrl={apiUrl} - preset={preset} - /> - ); - })} - </div> + ); + })} + </div> + ); + })()} </div> ))} </> diff --git a/packages/ui/src/components/PostPicker.tsx b/packages/ui/src/components/PostPicker.tsx index 91310672..6b000a66 100644 --- a/packages/ui/src/components/PostPicker.tsx +++ b/packages/ui/src/components/PostPicker.tsx @@ -27,7 +27,8 @@ const PostPicker: React.FC<PostPickerProps> = ({ onSelect }) => { const params = new URLSearchParams({ source: 'user', sourceId: user!.id, - limit: '100' + limit: '100', + contentType: 'posts' }); const res = await fetch(`${FEED_API_ENDPOINT}?${params}`); diff --git a/packages/ui/src/components/SEO.tsx b/packages/ui/src/components/SEO.tsx index a035e0fd..6c4e3541 100644 --- a/packages/ui/src/components/SEO.tsx +++ b/packages/ui/src/components/SEO.tsx @@ -5,6 +5,8 @@ interface SEOProps { title?: string; description?: string; image?: string; + canonicalUrl?: string; + hreflangTags?: { lang: string; href: string }[]; type?: string; twitterCard?: string; } @@ -13,6 +15,8 @@ export const SEO: React.FC<SEOProps> = ({ title, description, image, + canonicalUrl, + hreflangTags, type = 'website', twitterCard = 'summary_large_image' }) => { @@ -24,12 +28,21 @@ export const SEO: React.FC<SEOProps> = ({ {/* Standard metadata tags */} <title>{fullTitle} {description && } + {canonicalUrl && } + + {/* Hreflang alternate tags */} + {hreflangTags && hreflangTags.map(tag => ( + + ))} {/* Open Graph tags */} {description && } + + {image && } + {canonicalUrl && } {/* Twitter tags */} diff --git a/packages/ui/src/components/SmtpIntegrations.tsx b/packages/ui/src/components/SmtpIntegrations.tsx new file mode 100644 index 00000000..4323f10f --- /dev/null +++ b/packages/ui/src/components/SmtpIntegrations.tsx @@ -0,0 +1,272 @@ +/** + * SmtpIntegrations - manage connected SMTP servers in Profile > SMTP Servers + * Specialized for outgoing email campaigns. + */ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { + Mail, + Plus, + Trash2, + Loader2, + CheckCircle2, + AlertCircle, + Wifi, + WifiOff, + Eye, + EyeOff, + Send +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + listMailboxes, + saveMailbox, + deleteMailbox, + testMailbox, + type MailboxItem, + type MailboxInput, +} from '@/modules/contacts/client-mailboxes'; + +function StatusBadge({ status }: { status?: string }) { + if (status === 'ok') return Tested; + if (status === 'error') return Error; + return Not tested; +} + +interface AddSmtpDialogProps { + open: boolean; + onOpenChange: (v: boolean) => void; + onSaved: () => void; +} + +const EMPTY_FORM: MailboxInput = { + label: '', + host: 'smtp.mailersend.net', + port: 587, + tls: true, + user: '', + password: '', + type: 'smtp' +}; + +const AddSmtpDialog: React.FC = ({ open, onOpenChange, onSaved }) => { + const [form, setForm] = useState(EMPTY_FORM); + const [showPass, setShowPass] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { if (open) { setForm(EMPTY_FORM); setShowPass(false); } }, [open]); + + const handleSave = async () => { + if (!form.label.trim()) return toast.error('Label is required'); + if (!form.host.trim()) return toast.error('SMTP Host is required'); + if (!form.user.trim()) return toast.error('Username is required'); + if (!form.password?.trim()) return toast.error('Password/Token is required'); + + setSaving(true); + try { + await saveMailbox({ ...form, type: 'smtp' }); + toast.success('SMTP Server saved'); + onSaved(); + onOpenChange(false); + } catch (err: any) { + toast.error(err.message || 'Failed to save SMTP server'); + } finally { + setSaving(false); + } + }; + + const set = (key: keyof MailboxInput, value: any) => setForm(prev => ({ ...prev, [key]: value })); + + return ( + + + + Add Outgoing SMTP Server + + Configure a dedicated SMTP server for sending bulk emails without polling restrictions (e.g. MailerSend, SendGrid). + + + +
+
+ + set('label', e.target.value)} placeholder="e.g. MailerSend Account" /> +
+
+
+ + set('host', e.target.value)} placeholder="smtp.provider.com" /> +
+
+ + set('port', Number(e.target.value))} /> +
+
+
+ set('tls', v)} /> + +
+
+ + set('user', e.target.value)} placeholder="SMTP Username" /> +
+
+ +
+ set('password', e.target.value)} + placeholder="Token or password" + className="pr-10" + /> + +
+
+
+ + + + + +
+
+ ); +}; + +export const SmtpIntegrations: React.FC = () => { + const [mailboxes, setMailboxes] = useState([]); + const [loading, setLoading] = useState(true); + const [addOpen, setAddOpen] = useState(false); + const [testing, setTesting] = useState(null); + const [deleting, setDeleting] = useState(null); + + const load = async () => { + setLoading(true); + try { + const list = await listMailboxes(); + setMailboxes(list.filter(mb => mb.type === 'smtp')); + } catch (err: any) { + toast.error('Failed to load SMTP servers: ' + err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, []); + + const handleTest = async (id: string) => { + // Technically testMailbox does IMAP login. If it doesn't support SMTP checking, it may fail. + // We will mock it as untested or assume we build an SMTP tester later. + setTesting(id); + try { + const result = await testMailbox(id); + if (result.ok) toast.success('Connection seems ok (tested IMAP fallback logic).'); + else toast.error('Connection failed: ' + (result.error || 'Check credentials')); + await load(); + } catch (err: any) { + toast.error(err.message); + } finally { + setTesting(null); + } + }; + + const handleDelete = async (id: string) => { + setDeleting(id); + try { + await deleteMailbox(id); + toast.success('SMTP server removed'); + setMailboxes(prev => prev.filter(m => m.id !== id)); + } catch (err: any) { + toast.error(err.message); + } finally { + setDeleting(null); + } + }; + + return ( +
+
+
+

SMTP Sending Servers

+

+ Configure dedicated SMTP accounts for outgoing email campaigns. +

+
+ +
+ + {loading ? ( +
+ +
+ ) : mailboxes.length === 0 ? ( + + + +

No SMTP Servers Configured

+

+ Add a custom SMTP provider (like Mailgun, SendGrid or MailerSend) to improve campaign deliverability and isolate senders. +

+
+
+ ) : ( +
+ {mailboxes.map(mb => ( + + +
+
+
+ +
+
+ {mb.label} + {mb.user} +
+
+
+ +
+
+
+ +
+
Host
+
{mb.host}
+
Port
+
{mb.port}
+
+
+
+ ))} +
+ )} + + +
+ ); +}; diff --git a/packages/ui/src/components/StreamInvalidator.tsx b/packages/ui/src/components/StreamInvalidator.tsx index 20635252..e8eac81f 100644 --- a/packages/ui/src/components/StreamInvalidator.tsx +++ b/packages/ui/src/components/StreamInvalidator.tsx @@ -13,7 +13,9 @@ const INVALIDATION_RULES: Record qc.invalidateQueries({ queryKey: ['posts'] }); qc.invalidateQueries({ queryKey: ['pages'] }); // posts can embed in pages qc.invalidateQueries({ queryKey: ['feed'] }); + qc.invalidateQueries({ queryKey: ['widget-translations'] }); }, + 'posts': (id, qc) => INVALIDATION_RULES['post'](id, qc), 'picture': (id, qc) => { if (id) qc.invalidateQueries({ queryKey: ['picture', id] }); qc.invalidateQueries({ queryKey: ['pictures'] }); @@ -31,29 +33,39 @@ const INVALIDATION_RULES: Record if (id) qc.invalidateQueries({ queryKey: ['page', id] }); qc.invalidateQueries({ queryKey: ['pages'] }); qc.invalidateQueries({ queryKey: ['feed'] }); + // Depending on page content, widget translations could update, so invalidate i18n + qc.invalidateQueries({ queryKey: ['widget-translations'] }); }, + 'pages': (id, qc) => INVALIDATION_RULES['page'](id, qc), 'category': (_id, qc) => { qc.invalidateQueries({ queryKey: ['categories'] }); qc.invalidateQueries({ queryKey: ['pages'] }); // pages depend on categories qc.invalidateQueries({ queryKey: ['feed'] }); + qc.invalidateQueries({ queryKey: ['widget-translations'] }); }, + 'categories': (id, qc) => INVALIDATION_RULES['category'](id, qc), 'type': (_id, qc) => { qc.invalidateQueries({ queryKey: ['types'] }); qc.invalidateQueries({ queryKey: ['categories'] }); // categories depend on types qc.invalidateQueries({ queryKey: ['pages'] }); // โ†’ pages + qc.invalidateQueries({ queryKey: ['widget-translations'] }); }, + 'types': (id, qc) => INVALIDATION_RULES['type'](id, qc), 'layout': (id, qc) => { if (id) qc.invalidateQueries({ queryKey: ['layout', id] }); qc.invalidateQueries({ queryKey: ['layouts'] }); }, + 'layouts': (id, qc) => INVALIDATION_RULES['layout'](id, qc), 'glossary': (_id, qc) => { qc.invalidateQueries({ queryKey: ['i18n'] }); }, + 'translations': (id, qc) => INVALIDATION_RULES['glossary'](id, qc), 'i18n': (_id, qc) => { qc.invalidateQueries({ queryKey: ['i18n'] }); }, 'system': (_id, qc) => { - // Full flush โ€” invalidate everything + // System wide flush (often from system widget translations) + qc.invalidateQueries({ queryKey: ['widget-translations'] }); qc.invalidateQueries(); }, }; diff --git a/packages/ui/src/components/ThemeProvider.tsx b/packages/ui/src/components/ThemeProvider.tsx index be76852a..649c2a77 100644 --- a/packages/ui/src/components/ThemeProvider.tsx +++ b/packages/ui/src/components/ThemeProvider.tsx @@ -22,7 +22,7 @@ const ThemeProviderContext = createContext(initialState); export function ThemeProvider({ children, - defaultTheme = "light", + defaultTheme = "system", storageKey = "ui-theme", ...props }: ThemeProviderProps) { @@ -33,18 +33,21 @@ export function ThemeProvider({ useEffect(() => { const root = window.document.documentElement; - root.classList.remove("light", "dark"); - if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - root.classList.add(systemTheme); - return; + const applyTheme = () => { + root.classList.remove("light", "dark"); + root.classList.add(mediaQuery.matches ? "dark" : "light"); + }; + + applyTheme(); + + mediaQuery.addEventListener("change", applyTheme); + return () => mediaQuery.removeEventListener("change", applyTheme); } + root.classList.remove("light", "dark"); root.classList.add(theme); }, [theme]); diff --git a/packages/ui/src/components/ThemeToggle.tsx b/packages/ui/src/components/ThemeToggle.tsx index 846b0e9d..6b9ab5c4 100644 --- a/packages/ui/src/components/ThemeToggle.tsx +++ b/packages/ui/src/components/ThemeToggle.tsx @@ -1,20 +1,39 @@ -import { Moon, Sun } from "lucide-react"; +import { Monitor, Moon, Sun } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTheme } from "@/components/ThemeProvider"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; export function ThemeToggle() { - const { theme, setTheme } = useTheme(); + const { setTheme } = useTheme(); return ( - + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + ); } \ No newline at end of file diff --git a/packages/ui/src/components/TopNavigation.tsx b/packages/ui/src/components/TopNavigation.tsx index c79f183d..dfc150c4 100644 --- a/packages/ui/src/components/TopNavigation.tsx +++ b/packages/ui/src/components/TopNavigation.tsx @@ -3,7 +3,7 @@ import { useAuth } from "@/hooks/useAuth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Home, User, Upload, LogOut, LogIn, Camera, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity, ShoppingCart } from "lucide-react"; +import { Home, User, Upload, LogOut, LogIn, Wand2, Search, Grid3x3, Globe, ListFilter, Shield, Activity, ShoppingCart, Pencil, Settings, MessageSquare } from "lucide-react"; import { ThemeToggle } from "@/components/ThemeToggle"; import { useWizardContext } from "@/hooks/useWizardContext"; import { @@ -18,7 +18,6 @@ import { useProfiles } from "@/contexts/ProfilesContext"; import { useState, useRef, useEffect, lazy, Suspense } from "react"; import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n"; const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup }))); -import { useCartStore } from "@polymech/ecommerce"; const TopNavigation = () => { const { user, signOut, roles } = useAuth(); @@ -29,7 +28,21 @@ const TopNavigation = () => { const searchInputRef = useRef(null); const currentLang = getCurrentLang(); const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext(); - const cartItemCount = useCartStore((s) => s.itemCount); + + // Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load + const [cartItemCount, setCartItemCount] = useState(0); + useEffect(() => { + let unsubscribe: (() => void) | undefined; + import("@polymech/ecommerce").then(({ useCartStore }) => { + // Read initial value + setCartItemCount(useCartStore.getState().itemCount); + // Subscribe to changes + unsubscribe = useCartStore.subscribe((state) => { + setCartItemCount(state.itemCount); + }); + }).catch(() => { /* ecommerce not available */ }); + return () => unsubscribe?.(); + }, []); const authPath = '/auth'; @@ -99,7 +112,7 @@ const TopNavigation = () => {
{/* Logo / Brand */} - + Logo PolyMech @@ -110,7 +123,7 @@ const TopNavigation = () => { setSearchQuery(e.target.value)} @@ -126,6 +139,17 @@ const TopNavigation = () => { {/* Right Side Actions */}
+ {/* Support Chat - public */} + + {/* Mobile Search Button */} )} - {/* Mobile Navigation */} -
- -
- {/* Language Selector */} @@ -220,20 +230,6 @@ const TopNavigation = () => { - {user && ( - - )} - {user ? ( @@ -261,13 +257,46 @@ const TopNavigation = () => { Profile + + + + Edit Profile Page + + + + + + Settings + + + {roles.includes("admin") && ( - - - - Admin - - + <> + + + + Admin + + + + + + Edit Home Page + + + + + + I18n + + + + + + Chat + + + )} diff --git a/packages/ui/src/components/UserAvatarBlock.tsx b/packages/ui/src/components/UserAvatarBlock.tsx index 3bacb6d3..bd42e2cc 100644 --- a/packages/ui/src/components/UserAvatarBlock.tsx +++ b/packages/ui/src/components/UserAvatarBlock.tsx @@ -31,11 +31,11 @@ const UserAvatarBlock: React.FC = ({ const { profiles, fetchProfile } = useProfiles(); // Use prop if available, otherwise look up in context - const profile = profiles[userId]; + const profile = userId ? profiles[userId] : undefined; const effectiveAvatarUrl = avatarUrl || profile?.avatar_url; // Prefer prop displayName if truthy (e.g. override), else usage context - const effectiveDisplayName = displayName || profile?.display_name || `User ${userId.slice(0, 8)}`; + const effectiveDisplayName = displayName || profile?.display_name || (userId ? `User ${userId.slice(0, 8)}` : 'Unknown'); const getOptimizedAvatarUrl = (url?: string | null) => { if (!url) return undefined; @@ -63,7 +63,7 @@ const UserAvatarBlock: React.FC = ({ useEffect(() => { // If we don't have the profile in context, ask for it - if (!profile) { + if (!profile && userId) { fetchProfile(userId); } }, [userId, profile, fetchProfile]); diff --git a/packages/ui/src/components/UserInfo.tsx b/packages/ui/src/components/UserInfo.tsx new file mode 100644 index 00000000..328552f2 --- /dev/null +++ b/packages/ui/src/components/UserInfo.tsx @@ -0,0 +1,147 @@ +import { Link, useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Image as ImageIcon, Plus, User, Camera } from "lucide-react"; +import { T } from "@/i18n"; + +interface Collection { + id: string; + name: string; + description: string | null; + slug: string; + is_public: boolean; + created_at: string; +} + +interface UserInfoProps { + userProfile: { + id: string; + username: string | null; + display_name: string | null; + bio: string | null; + avatar_url: string | null; + created_at: string; + }; + userId: string; + isOwnProfile: boolean; + collections?: Collection[]; +} + +const UserInfo = ({ userProfile, userId, isOwnProfile, collections = [] }: UserInfoProps) => { + const navigate = useNavigate(); + + return ( +
+
+
+ {/* Avatar */} +
+ {userProfile.avatar_url ? ( + {userProfile.display_name + ) : ( + + )} +
+ + {/* Info */} +
+ {/* Name row + actions */} +
+

+ {userProfile.username || userProfile.display_name?.replace(/\s+/g, '').toLowerCase() || `user${userId?.slice(0, 8)}`} +

+ {isOwnProfile ? ( +
+ + + + +
+ ) : ( +
+ + +
+ )} +
+ + {/* Stats row */} +
+ 0 posts + 0 followers + 0 following +
+ + {/* Display name + bio */} + {userProfile.display_name && ( +
{userProfile.display_name}
+ )} + {userProfile.bio && ( +

{userProfile.bio}

+ )} +
+
+ + {/* Collections row */} +
+ +
+ +
+ + Collections + + + + {isOwnProfile && ( + + )} + + {collections.slice(0, 5).map((collection) => ( + +
+ +
+ + {collection.name} + + + ))} +
+
+
+ ); +}; + +export default UserInfo; diff --git a/packages/ui/src/components/UserPictures.tsx b/packages/ui/src/components/UserPictures.tsx index 24fc5868..cca767d1 100644 --- a/packages/ui/src/components/UserPictures.tsx +++ b/packages/ui/src/components/UserPictures.tsx @@ -9,7 +9,7 @@ import { normalizeMediaType, isVideoType, detectMediaType } from "@/lib/mediaReg import { T, translate } from "@/i18n"; import { Loader2, ImageOff, Trash2 } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { DeleteDialog } from "@/pages/Post/components/DeleteDialogs"; +import { DeleteDialog } from "@/modules/posts/views/components/DeleteDialogs"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; diff --git a/packages/ui/src/components/VersionSelector.tsx b/packages/ui/src/components/VersionSelector.tsx index 80505feb..02396397 100644 --- a/packages/ui/src/components/VersionSelector.tsx +++ b/packages/ui/src/components/VersionSelector.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Check, Image as ImageIcon, Eye, EyeOff, Trash2 } from 'lucide-react'; -import { fetchPictureById, fetchVersions, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures'; +import { fetchPictureById, updatePicture, deletePictures, fetchUserPictures } from '@/modules/posts/client-pictures'; import { useAuth } from '@/hooks/useAuth'; import { toast } from 'sonner'; import { T, translate } from '@/i18n'; @@ -60,10 +60,37 @@ const VersionSelector: React.FC = ({ const currentPicture = await fetchPictureById(currentPictureId); if (!currentPicture) throw new Error('Picture not found'); - // Fetch all versions via API - const allVersions = await fetchVersions(currentPicture, user.id); + // Fetch all user pictures and walk the full tree (same as VersionMap) + const allPictures = await fetchUserPictures(currentPicture.user_id); + if (!allPictures || allPictures.length === 0) { + setVersions([]); + return; + } - setVersions(allVersions || []); + const pictureMap = new Map(allPictures.map((p: any) => [p.id, p])); + + // Walk up to find the true root + let trueRootId = currentPicture.id; + let current: any = pictureMap.get(currentPicture.id); + while (current?.parent_id && pictureMap.has(current.parent_id)) { + trueRootId = current.parent_id; + current = pictureMap.get(current.parent_id); + } + + // Collect all descendants from root + const tree: any[] = []; + const visited = new Set(); + const collect = (nodeId: string) => { + if (visited.has(nodeId)) return; + const node = pictureMap.get(nodeId); + if (!node) return; + visited.add(nodeId); + tree.push(node); + allPictures.filter((p: any) => p.parent_id === nodeId).forEach((child: any) => collect(child.id)); + }; + collect(trueRootId); + + setVersions(tree || []); } catch (error) { console.error('Error loading versions:', error); toast.error(translate('Failed to load image versions')); @@ -77,24 +104,27 @@ const VersionSelector: React.FC = ({ setUpdating(versionId); try { - // Unselect all versions in this image tree, then select the chosen one - await Promise.all( - versions.map(v => updatePicture(v.id, { is_selected: v.id === versionId } as any)) - ); + const targetVersion = versions.find(v => v.id === versionId); + if (!targetVersion) return; + + // Toggle: if already selected, unselect; otherwise select + const newSelected = !targetVersion.is_selected; + + await updatePicture(versionId, { is_selected: newSelected } as any); // Update local state setVersions(prevVersions => prevVersions.map(v => ({ ...v, - is_selected: v.id === versionId + is_selected: v.id === versionId ? newSelected : v.is_selected })) ); - toast.success(translate('Version selected successfully!')); + toast.success(translate(newSelected ? 'Version selected!' : 'Version unselected!')); onVersionSelect(versionId); } catch (error) { - console.error('Error selecting version:', error); - toast.error(translate('Failed to select version')); + console.error('Error toggling version selection:', error); + toast.error(translate('Failed to update version')); } finally { setUpdating(null); } @@ -239,8 +269,8 @@ const VersionSelector: React.FC = ({ onClick={() => handleVersionSelect(version.id)} disabled={updating === version.id} > - {updating === version.id ? 'Selecting...' : - version.is_selected ? 'Selected' : 'Select'} + {updating === version.id ? 'Updating...' : + version.is_selected ? 'Unselect' : 'Select'}
- {/* Desktop Hover Overlay - hidden on mobile, and hidden in feed variant. Also hidden when playing to avoid blocking controls. */} - {showContent && variant === 'grid' && !isPlaying && ( + {/* TESTING: Entire desktop hover overlay disabled */} + {false && showContent && variant === 'grid' && !isPlaying && (
@@ -538,12 +539,14 @@ const VideoCard = ({
+ {/* TESTING: hover title+description disabled

{title}

{description && (
)} + */}
- +
- -
+ )} - {/* ACL List */} -
-

Active Permissions (Mount: {mount})

-
-
- - - Path - Subject - Permissions - - - - - {loading ? ( + {/* ACL List โ€” hidden in compact mode (uses table, too wide) */} + {!compact && ( +
+

Active Permissions (Mount: {mount})

+
+
+ - - - + Path + Subject + Permissions + - ) : sortedEntries.length === 0 ? ( - - - No active permissions found. - - - ) : ( - sortedEntries.map((entry, i) => ( - - - {entry.path || '/'} - {entry.path === path && Current} - - - {entry.userId === ANONYMOUS_USER_ID ? ( -
- - Anonymous -
- ) : entry.userId ? ( -
- - {profiles[entry.userId]?.display_name || profiles[entry.userId]?.username || 'User'} - - - {entry.userId.slice(0, 8)}... - -
- ) : entry.group ? ( - Group: {entry.group} - ) : 'Unknown'} -
- -
- {entry.permissions.map(p => ( - {p} - ))} -
-
- - +
+ + {loading ? ( + + + - )) - )} - -
+ ) : sortedEntries.length === 0 ? ( + + + No active permissions found. + + + ) : ( + sortedEntries.map((entry, i) => ( + + + {entry.path || '/'} + {entry.path === path && Current} + + + {entry.userId === ANONYMOUS_USER_ID ? ( +
+ + Anonymous +
+ ) : entry.userId ? ( +
+ + {profiles[entry.userId]?.display_name || profiles[entry.userId]?.username || 'User'} + + + {entry.userId.slice(0, 8)}... + +
+ ) : entry.group ? ( + Group: {entry.group} + ) : 'Unknown'} +
+ +
+ {entry.permissions.map(p => ( + {p} + ))} +
+
+ + + +
+ )) + )} + +
+ - + )} diff --git a/packages/ui/src/components/admin/AdminSidebar.tsx b/packages/ui/src/components/admin/AdminSidebar.tsx index f7551b1d..9ffd9554 100644 --- a/packages/ui/src/components/admin/AdminSidebar.tsx +++ b/packages/ui/src/components/admin/AdminSidebar.tsx @@ -10,30 +10,58 @@ import { useSidebar } from "@/components/ui/sidebar"; import { T, translate } from "@/i18n"; -import { LayoutDashboard, Users, Server, Shield, AlertTriangle, ChartBar, Settings, Database } from "lucide-react"; +import { LayoutDashboard, Users, Server, Shield, AlertTriangle, ChartBar, Settings, Database, Video } from "lucide-react"; +import { useIsMobile } from "@/hooks/use-mobile"; import { useLocation, useNavigate } from "react-router-dom"; -export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations' | 'analytics' | 'storage'; +export type AdminActiveSection = 'dashboard' | 'users' | 'server' | 'bans' | 'violations' | 'analytics' | 'storage' | 'videos'; + +const menuItems = [ + { id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard }, + { id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users }, + { id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server }, + { id: 'bans' as AdminActiveSection, label: translate('Bans'), icon: Shield }, + { id: 'violations' as AdminActiveSection, label: translate('Violations'), icon: AlertTriangle }, + { id: 'analytics' as AdminActiveSection, label: translate('Analytics'), icon: ChartBar }, + { id: 'storage' as AdminActiveSection, label: translate('Storage'), icon: Database }, + { id: 'videos' as AdminActiveSection, label: translate('Videos'), icon: Video }, +]; export const AdminSidebar = () => { const { state } = useSidebar(); const location = useLocation(); const navigate = useNavigate(); const isCollapsed = state === "collapsed"; + const isMobile = useIsMobile(); - const currentSection = location.pathname.split('/').pop() as AdminActiveSection || 'users'; - - const menuItems = [ - { id: 'dashboard' as AdminActiveSection, label: translate('Dashboard'), icon: LayoutDashboard }, - { id: 'users' as AdminActiveSection, label: translate('Users'), icon: Users }, - { id: 'server' as AdminActiveSection, label: translate('Server'), icon: Server }, - { id: 'bans' as AdminActiveSection, label: translate('Bans'), icon: Shield }, - { id: 'violations' as AdminActiveSection, label: translate('Violations'), icon: AlertTriangle }, - { id: 'analytics' as AdminActiveSection, label: translate('Analytics'), icon: ChartBar }, - { id: 'storage' as AdminActiveSection, label: translate('Storage'), icon: Database }, - ]; + // โ”€โ”€ Mobile: horizontal scrollable nav strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (isMobile) { + return ( + + ); + } + // โ”€โ”€ Desktop: standard vertical sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ return ( @@ -42,9 +70,7 @@ export const AdminSidebar = () => { {menuItems.map((item) => { - // Handle base /admin/ path matching 'users' if we redirect const isActive = (item.id === 'users' && (location.pathname === '/admin' || location.pathname === '/admin/')) || location.pathname.includes(`/admin/${item.id}`); - return ( = ({ value, onCha }; return ( -
+
{ALL_PERMISSIONS.map(p => (
); } diff --git a/packages/ui/src/components/admin/VideoManager.tsx b/packages/ui/src/components/admin/VideoManager.tsx new file mode 100644 index 00000000..c686d030 --- /dev/null +++ b/packages/ui/src/components/admin/VideoManager.tsx @@ -0,0 +1,469 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Loader2, Trash2, Search, Video, ExternalLink, RefreshCw, HardDrive, Database } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { T, translate } from '@/i18n'; +import { toast } from 'sonner'; +import { + fetchVideos, deleteVideos, scanVideoDisk, deleteVideoFolders, + type VideoEntry, type DiskVideoFolder +} from '@/modules/posts/client-videos'; + +/** Normalize URLs: DB may store relative paths or old localhost URLs. */ +const rewriteUrl = (url: string): string => { + if (!url) return url; + const serverBase = import.meta.env.VITE_SERVER_IMAGE_API_URL; + if (!serverBase) return url; + const apiIdx = url.indexOf('/api/'); + if (apiIdx !== -1) return `${serverBase}${url.slice(apiIdx)}`; + return url; +}; + +const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +}; + +const formatDuration = (seconds?: number) => { + if (!seconds) return 'โ€”'; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } catch { return dateStr; } +}; + +const VideoManager: React.FC = () => { + // Database state + const [dbVideos, setDbVideos] = useState([]); + const [dbLoading, setDbLoading] = useState(true); + const [deleting, setDeleting] = useState(false); + const [selectedDbIds, setSelectedDbIds] = useState>(new Set()); + + // Disk state + const [diskFolders, setDiskFolders] = useState([]); + const [diskLoading, setDiskLoading] = useState(true); + const [storageDir, setStorageDir] = useState(''); + const [selectedDiskFolders, setSelectedDiskFolders] = useState>(new Set()); + const [deletingFolders, setDeletingFolders] = useState(false); + + // DB-only orphans + const [selectedDbOnlyIds, setSelectedDbOnlyIds] = useState>(new Set()); + const [deletingDbOnly, setDeletingDbOnly] = useState(false); + + const [searchText, setSearchText] = useState(''); + + const loadAll = useCallback(async () => { + const dbPromise = (async () => { + try { + setDbLoading(true); + setDbVideos(await fetchVideos()); + } catch (err: any) { + toast.error(translate('Failed to load videos from DB'), { description: err.message }); + } finally { setDbLoading(false); } + })(); + + const diskPromise = (async () => { + try { + setDiskLoading(true); + const result = await scanVideoDisk(); + setDiskFolders(result.folders); + setStorageDir(result.storageDir); + } catch (err: any) { + toast.error(translate('Failed to scan disk'), { description: err.message }); + } finally { setDiskLoading(false); } + })(); + + await Promise.all([dbPromise, diskPromise]); + }, []); + + useEffect(() => { loadAll(); }, [loadAll]); + + // Cross-reference maps + const dbByPlaybackId = useMemo(() => { + const map = new Map(); + for (const v of dbVideos) { + const pid = v.meta?.mux_playback_id; + if (pid) map.set(pid, v); + } + return map; + }, [dbVideos]); + + const diskFolderNames = useMemo(() => new Set(diskFolders.map(f => f.name)), [diskFolders]); + + const orphanDiskFolders = useMemo(() => + diskFolders.filter(f => !dbByPlaybackId.has(f.name)), + [diskFolders, dbByPlaybackId] + ); + + const orphanDbEntries = useMemo(() => + dbVideos.filter(v => { + const pid = v.meta?.mux_playback_id; + return !pid || !diskFolderNames.has(pid); + }), + [dbVideos, diskFolderNames] + ); + + const filteredDbVideos = useMemo(() => { + if (!searchText) return dbVideos; + const q = searchText.toLowerCase(); + return dbVideos.filter(v => + (v.title || '').toLowerCase().includes(q) || + (v.id || '').toLowerCase().includes(q) || + (v.meta?.mux_playback_id || '').toLowerCase().includes(q) + ); + }, [dbVideos, searchText]); + + // โ”€โ”€ Delete handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const handleDeleteDbVideos = async () => { + const ids = Array.from(selectedDbIds); + if (!ids.length) return; + if (!confirm(translate(`Delete ${ids.length} video(s)? Removes files from disk AND database.`))) return; + try { + setDeleting(true); + const r = await deleteVideos(ids); + toast.success(`Deleted ${r.deleted} video(s)`); + setSelectedDbIds(new Set()); + await loadAll(); + } catch (err: any) { toast.error(err.message); } + finally { setDeleting(false); } + }; + + const handleDeleteDiskFolders = async () => { + const names = Array.from(selectedDiskFolders); + if (!names.length) return; + if (!confirm(translate(`Delete ${names.length} disk folder(s)? This removes files permanently.`))) return; + try { + setDeletingFolders(true); + const r = await deleteVideoFolders(names); + toast.success(`Deleted ${r.deleted} folder(s)`); + setSelectedDiskFolders(new Set()); + await loadAll(); + } catch (err: any) { toast.error(err.message); } + finally { setDeletingFolders(false); } + }; + + const handleDeleteDbOnly = async () => { + const ids = Array.from(selectedDbOnlyIds); + if (!ids.length) return; + if (!confirm(translate(`Delete ${ids.length} DB-only entry/entries? These have no files on disk.`))) return; + try { + setDeletingDbOnly(true); + const r = await deleteVideos(ids); + toast.success(`Deleted ${r.deleted} entry/entries`); + setSelectedDbOnlyIds(new Set()); + await loadAll(); + } catch (err: any) { toast.error(err.message); } + finally { setDeletingDbOnly(false); } + }; + + const isLoading = dbLoading || diskLoading; + + return ( +
+ {/* Header */} +
+
+ + {/* Summary Cards */} +
+
+
+ In Database +
+
{dbLoading ? 'โ€ฆ' : dbVideos.length}
+
+
+
+ On Disk +
+
{diskLoading ? 'โ€ฆ' : diskFolders.length}
+ {!diskLoading && ( +
+ {formatBytes(diskFolders.reduce((sum, f) => sum + f.totalSize, 0))} +
+ )} +
+
+
+ Disk Only +
+
{isLoading ? 'โ€ฆ' : orphanDiskFolders.length}
+
No DB entry
+
+
+
+ DB Only +
+
{isLoading ? 'โ€ฆ' : orphanDbEntries.length}
+
No disk files
+
+
+ + {/* Actions Bar */} +
+ + + {selectedDbIds.size > 0 && ( + + )} + + {dbVideos.length > 0 && ( +
+ + setSearchText(e.target.value)} className="h-8" /> +
+ )} +
+ + {/* โ”€โ”€ Database Videos Table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} +
+

+ Database Videos +

+ + {dbLoading ? ( +
+ ) : filteredDbVideos.length === 0 ? ( +
No videos found in database.
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + {filteredDbVideos.map(video => { + const hasDisk = video.meta?.mux_playback_id ? diskFolderNames.has(video.meta.mux_playback_id) : false; + return ( + + + + + + + + + + + ); + })} + +
+ 0 && selectedDbIds.size === filteredDbVideos.length} + onCheckedChange={(c) => setSelectedDbIds(c ? new Set(filteredDbVideos.map(v => v.id)) : new Set())} + /> + ThumbTitleDurationResDiskCreated
+ setSelectedDbIds(prev => { + const next = new Set(prev); + c ? next.add(video.id) : next.delete(video.id); + return next; + })} /> + + {video.thumbnail_url ? ( + + ) : ( +
+
+ )} +
+
{video.title || translate('Untitled')}
+
{video.id.slice(0, 8)}โ€ฆ
+
{formatDuration(video.meta?.duration)} + {video.meta?.max_stored_resolution || 'โ€”'} + + + {formatDate(video.created_at)} + {video.image_url && ( + + + + )} +
+
+ )} +
+ + {/* โ”€โ”€ Orphan Disk Folders (no DB entry) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {!isLoading && orphanDiskFolders.length > 0 && ( +
+
+

+ Disk Only + โ€” no database entry +

+
+ + {selectedDiskFolders.size > 0 && ( + + )} +
+
+
+ + + + + + + + + + + {orphanDiskFolders.map(f => ( + + + + + + + ))} + +
+ 0 && selectedDiskFolders.size === orphanDiskFolders.length} + onCheckedChange={(c) => setSelectedDiskFolders(c ? new Set(orphanDiskFolders.map(f => f.name)) : new Set())} + /> + Folder NameFilesSize
+ setSelectedDiskFolders(prev => { + const next = new Set(prev); + c ? next.add(f.name) : next.delete(f.name); + return next; + })} /> + {f.name}{f.fileCount}{formatBytes(f.totalSize)}
+
+
+ )} + + {/* โ”€โ”€ Orphan DB Entries (no disk files) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} + {!isLoading && orphanDbEntries.length > 0 && ( +
+
+

+ DB Only + โ€” no files on disk +

+
+ + {selectedDbOnlyIds.size > 0 && ( + + )} +
+
+
+ + + + + + + + + + + + {orphanDbEntries.map(v => ( + + + + + + + + ))} + +
+ 0 && selectedDbOnlyIds.size === orphanDbEntries.length} + onCheckedChange={(c) => setSelectedDbOnlyIds(c ? new Set(orphanDbEntries.map(v => v.id)) : new Set())} + /> + TitleIDPlayback IDCreated
+ setSelectedDbOnlyIds(prev => { + const next = new Set(prev); + c ? next.add(v.id) : next.delete(v.id); + return next; + })} /> + {v.title || translate('Untitled')}{v.id.slice(0, 12)}โ€ฆ{v.meta?.mux_playback_id?.slice(0, 12) || 'โ€”'}โ€ฆ{formatDate(v.created_at)}
+
+
+ )} + + {/* Storage Dir Info */} + {storageDir && ( +
+ Storage directory: {storageDir} +
+ )} +
+ ); +}; + +export default VideoManager; diff --git a/packages/ui/src/components/containers/ContainerPropertyPanel.tsx b/packages/ui/src/components/containers/ContainerPropertyPanel.tsx index 0b10c6a0..f4e5c24f 100644 --- a/packages/ui/src/components/containers/ContainerPropertyPanel.tsx +++ b/packages/ui/src/components/containers/ContainerPropertyPanel.tsx @@ -21,10 +21,13 @@ export const ContainerPropertyPanel: React.FC = ({ // Find the container in layout const findContainer = useCallback((containers: LayoutContainer[], id: string): LayoutContainer | null => { + if (!containers) return null; for (const c of containers) { if (c.id === id) return c; - const found = findContainer(c.children, id); - if (found) return found; + if (c.children?.length) { + const found = findContainer(c.children, id); + if (found) return found; + } } return null; }, []); diff --git a/packages/ui/src/components/containers/ContainerSettingsManager.tsx b/packages/ui/src/components/containers/ContainerSettingsManager.tsx index 67e372b4..b7b86f1e 100644 --- a/packages/ui/src/components/containers/ContainerSettingsManager.tsx +++ b/packages/ui/src/components/containers/ContainerSettingsManager.tsx @@ -69,7 +69,11 @@ export const ContainerSettingsManager: React.FC = return ( - + e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > Container Settings @@ -128,11 +132,12 @@ export const ContainerSettingsManager: React.FC = id="collapsible" checked={settings.collapsible} onCheckedChange={(checked) => { - updateSetting('collapsible', checked); - // If disabling collapsible, also set collapsed to false - if (!checked) { - updateSetting('collapsed', false); - } + setSettings(prev => ({ + ...prev, + collapsible: checked, + // If disabling collapsible, also set collapsed to false + ...(checked ? {} : { collapsed: false }), + })); }} />
diff --git a/packages/ui/src/components/feed/FeedCard.tsx b/packages/ui/src/components/feed/FeedCard.tsx index 53b13fab..11bf4344 100644 --- a/packages/ui/src/components/feed/FeedCard.tsx +++ b/packages/ui/src/components/feed/FeedCard.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { FeedPost } from '@/lib/db'; +import { FeedPost } from '@/modules/posts/client-posts'; import { FeedCarousel } from './FeedCarousel'; import { Heart } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -84,10 +84,26 @@ export const FeedCard: React.FC = ({ onNavigate?.(post.id); }; - if (carouselItems.length === 0) return null; + if (carouselItems.length === 0) { + if (post.type === 'page-vfs-file' || post.type === 'page-vfs-folder' || post.type === 'page-intern' || post.type === 'page-external') { + return ( +
+
{ + if (post.meta?.url) navigate(post.meta.url); + else if (post.type === 'page-intern' && post.meta?.slug) navigate(`/user/${post.author?.username || post.user_id}/pages/${post.meta.slug}`); + else onNavigate?.(post.id); + }} className="cursor-pointer"> +

{post.title}

+ {post.description &&

{post.description}

} +
+
+ ); + } + return null; + } return ( -
+
{/* Media Carousel */} diff --git a/packages/ui/src/components/feed/MobileFeed.tsx b/packages/ui/src/components/feed/MobileFeed.tsx index 9c945912..0a1180dd 100644 --- a/packages/ui/src/components/feed/MobileFeed.tsx +++ b/packages/ui/src/components/feed/MobileFeed.tsx @@ -8,13 +8,18 @@ import { useFeedData, FeedSortOption } from '@/hooks/useFeedData'; import { useFeedCache } from '@/contexts/FeedCacheContext'; import { useOrganization } from '@/contexts/OrganizationContext'; import { FeedPost } from '@/modules/posts/client-posts'; +import { T } from '@/i18n'; interface MobileFeedProps { - source?: 'home' | 'collection' | 'tag' | 'user'; + source?: 'home' | 'collection' | 'tag' | 'user' | 'search'; sourceId?: string; onNavigate?: (id: string) => void; sortBy?: FeedSortOption; categorySlugs?: string[]; + categoryIds?: string[]; + contentType?: 'posts' | 'pages' | 'pictures' | 'files'; + visibilityFilter?: 'invisible' | 'private'; + center?: boolean; } const PRELOAD_BUFFER = 3; @@ -24,7 +29,11 @@ export const MobileFeed: React.FC = ({ sourceId, onNavigate, sortBy = 'latest', - categorySlugs + categorySlugs, + categoryIds, + contentType, + visibilityFilter, + center }) => { const { user } = useAuth(); @@ -36,7 +45,10 @@ export const MobileFeed: React.FC = ({ source, sourceId, sortBy, - categorySlugs + categorySlugs, + categoryIds, + contentType, + visibilityFilter }); // Scroll Restoration Logic @@ -116,18 +128,72 @@ export const MobileFeed: React.FC = ({ ); } - return ( -
- {posts.map((post, index) => ( + const isSearchTabAllOrFiles = source === 'search' && (!contentType || contentType === 'files'); + + const renderItems = () => { + if (!isSearchTabAllOrFiles) { + return posts.map((post, index) => ( - ))} + )); + } + + const getSearchGroup = (item: any): string => { + if (item.type === 'page-vfs-folder') return 'Folders'; + if (item._searchSource === 'picture') return 'Pictures'; + if (item._searchSource === 'file') { + if (item.thumbnail_url || item.cover || (item.pictures && item.pictures.length > 0)) return 'Pictures'; + return 'Files'; + } + if (item._searchSource === 'page') return 'Pages'; + if (item._searchSource === 'post') return 'Posts'; + return 'Posts'; + }; + + const groups = new Map(); + for (const post of posts) { + const group = getSearchGroup(post); + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(post); + } + + const orderedGroups = ['Pages', 'Folders', 'Posts', 'Pictures', 'Files']; + const elements: React.ReactNode[] = []; + + for (const group of orderedGroups) { + if (groups.has(group)) { + elements.push( +
+ {group} +
+ ); + elements.push( + ...groups.get(group)!.map((post: any, index: number) => ( + + )) + ); + } + } + + return elements; + }; + + return ( +
+ {renderItems()}
); }; diff --git a/packages/ui/src/components/grids/gridUtils.ts b/packages/ui/src/components/grids/gridUtils.ts new file mode 100644 index 00000000..dcd4e063 --- /dev/null +++ b/packages/ui/src/components/grids/gridUtils.ts @@ -0,0 +1,130 @@ +import type { GridFilterModel, GridSortModel, GridColumnVisibilityModel } from '@mui/x-data-grid'; + +// Convert filter model to human-readable URL params +export const filterModelToParams = (filterModel: GridFilterModel): Record => { + const params: Record = {}; + + filterModel.items.forEach((item) => { + if (item.field && item.operator && item.value !== undefined && item.value !== null) { + // Create param like: filter_city_contains=Barcelona + const key = `filter_${item.field}_${item.operator}`; + params[key] = String(item.value); + } + }); + + return params; +}; + +// Convert URL params back to filter model +export const paramsToFilterModel = (searchParams: URLSearchParams): GridFilterModel => { + const items: any[] = []; + + searchParams.forEach((value, key) => { + if (key.startsWith('filter_')) { + // Parse: filter_city_contains -> field: city, operator: contains + // Handle potential spaces in keys + const cleanKey = key.trim(); + const parts = cleanKey.replace('filter_', '').split('_'); + if (parts.length >= 2) { + const operator = parts.pop(); // Last part is operator + const field = parts.join('_'); // Rest is field name (handles fields with underscores) + + if (operator) { + items.push({ + field, + operator: operator.trim(), + value, + }); + } + } + } + }); + + return { items }; +}; + +// Convert sort model to URL params +export const sortModelToParams = (sortModel: GridSortModel): Record => { + const params: Record = {}; + if (sortModel.length > 0) { + const { field, sort } = sortModel[0]; + if (sort) { + params.sort = `${field}:${sort}`; + } + } + return params; +}; + +// Convert URL params back to sort model +export const paramsToSortModel = (searchParams: URLSearchParams): GridSortModel => { + const sortParam = searchParams.get('sort'); + if (sortParam) { + // Handle encoded spaces or trailing spaces + const cleanParam = sortParam.trim(); + const [field, sort] = cleanParam.split(':'); + + const cleanSort = sort?.trim(); + + if (field && (cleanSort === 'asc' || cleanSort === 'desc')) { + return [{ field, sort: cleanSort }]; + } + } + return []; +}; + +// Convert visibility model to URL params +export const visibilityModelToParams = (model: GridColumnVisibilityModel, defaultHidden: string[] = []): Record => { + const hiddenCols = Object.entries(model) + .filter(([_, visible]) => !visible) + .map(([field]) => field); + + // Sort logic to make comparison consistent + hiddenCols.sort(); + const sortedDefault = [...defaultHidden].sort(); + + const isDefault = + hiddenCols.length === sortedDefault.length && + hiddenCols.every((val, index) => val === sortedDefault[index]); + + if (isDefault) { + return {}; // No param needed if it matches default + } + + if (hiddenCols.length === 0) { + return { hidden: '' }; // Explicitly show all (override default) + } + + return { hidden: hiddenCols.join(',') }; +}; + +// Convert URL params back to visibility model +export const paramsToVisibilityModel = (searchParams: URLSearchParams, defaultHidden: string[] = []): GridColumnVisibilityModel => { + const hiddenParam = searchParams.get('hidden'); + + // Case 1: No param -> Use defaults + if (hiddenParam === null) { + const model: GridColumnVisibilityModel = {}; + defaultHidden.forEach(col => { + model[col] = false; + }); + return model; + } + + // Case 2: Empty param (hidden=) -> Show all + if (hiddenParam === '') { + return {}; + } + + // Case 3: Explicit hidden columns + const model: GridColumnVisibilityModel = {}; + hiddenParam.split(',').forEach(field => { + if (field) model[field] = false; + }); + return model; +}; + +// Get column order from URL params +export const paramsToColumnOrder = (searchParams: URLSearchParams): string[] => { + const orderParam = searchParams.get('order'); + return orderParam ? orderParam.split(',') : []; +}; diff --git a/packages/ui/src/components/json/JSONTreeWalker.tsx b/packages/ui/src/components/json/JSONTreeWalker.tsx new file mode 100644 index 00000000..c9b34677 --- /dev/null +++ b/packages/ui/src/components/json/JSONTreeWalker.tsx @@ -0,0 +1,355 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Type as TypeIcon, Hash, ToggleLeft, Braces, ChevronRight, Home, List, Search } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// --- Types --- + +export interface NavDisplayItem { + key: string; + value: any; + displayType: string; + displayValue: string; + canDrillIn: boolean; +} + +// --- Helpers --- + +function getIconForType(type: string) { + switch (type) { + case 'number': return Hash; + case 'boolean': return ToggleLeft; + case 'json': + case 'object': + case 'array': return Braces; + default: return TypeIcon; + } +} + +export function resolveNavItems(data: any, path: string[]): NavDisplayItem[] | null { + try { + let obj = data; + for (const seg of path) { + if (obj == null || typeof obj !== 'object') return null; + obj = Array.isArray(obj) ? obj[Number(seg)] : obj[seg]; + } + if (obj == null || typeof obj !== 'object') return null; + return Object.entries(obj).map(([key, value]) => { + const isObj = value !== null && typeof value === 'object'; + const isArr = Array.isArray(value); + let displayType: string = typeof value; + if (value === null) displayType = 'null'; + else if (isArr) displayType = 'array'; + else if (isObj) displayType = 'object'; + const displayValue = isArr + ? `[${(value as any[]).length} items]` + : isObj + ? `{${Object.keys(value as object).length} keys}` + : String(value); + return { key, value, displayType, displayValue, canDrillIn: isObj }; + }); + } catch { + return null; + } +} + +// --- Sub-components --- + +const NavItemRow = ({ item, isSelected, onSelect, onDrillIn }: { + item: NavDisplayItem; + isSelected: boolean; + onSelect: () => void; + onDrillIn: () => void; +}) => { + const iconType = item.canDrillIn ? 'json' : item.displayType === 'number' ? 'number' : item.displayType === 'boolean' ? 'boolean' : 'string'; + const Icon = getIconForType(iconType); + return ( +
{ e.stopPropagation(); onSelect(); }} + onDoubleClick={item.canDrillIn ? onDrillIn : undefined} + className={cn( + "p-3 border rounded-md mb-2 cursor-pointer flex items-center justify-between group transition-colors", + isSelected ? "ring-2 ring-primary border-primary bg-primary/5" : "hover:border-primary/50 bg-background" + )} + > +
+ +
+ {item.key} +
+ {item.displayType} + {item.displayValue} +
+
+
+ {item.canDrillIn && ( + + )} +
+ ); +}; + +// --- Detail Pane --- + +const DetailPane = ({ item }: { item: NavDisplayItem | null }) => { + if (!item) return ( +
+ +

Select an item to inspect

+

Use arrow keys to navigate

+
+ ); + + return ( +
+
+ +
{item.key}
+
+
+ +
{item.displayType}
+
+
+ + {item.canDrillIn ? ( +
+                        {JSON.stringify(item.value, null, 2)}
+                    
+ ) : ( +
+ {String(item.value)} +
+ )} +
+
+ ); +}; + +// --- Main Component --- + +export interface JSONTreeWalkerProps { + /** The JSON data to inspect. Can be an object, array, or any JSON-serializable value. */ + data: any; + /** Optional label shown in the breadcrumb root. */ + rootLabel?: string; + /** Extra CSS class on the outer wrapper. */ + className?: string; + /** Called when exiting from the root level (Escape or ArrowLeft at root). */ + onExit?: () => void; +} + +export const JSONTreeWalker = ({ data, rootLabel = 'Root', className, onExit }: JSONTreeWalkerProps) => { + const [path, setPath] = useState([]); + const [selectedKey, setSelectedKey] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const containerRef = useRef(null); + + // Parse if string + const parsedData = useMemo(() => { + if (typeof data === 'string') { + try { return JSON.parse(data); } catch { return data; } + } + return data; + }, [data]); + + // Resolve items at the current path + const navItems = useMemo(() => { + if (parsedData == null || typeof parsedData !== 'object') return null; + return resolveNavItems(parsedData, path); + }, [parsedData, path]); + + // Filter by search + const filteredItems = useMemo(() => { + if (!navItems) return null; + if (!searchTerm.trim()) return navItems; + const term = searchTerm.toLowerCase(); + return navItems.filter(item => + item.key.toLowerCase().includes(term) || + item.displayValue.toLowerCase().includes(term) + ); + }, [navItems, searchTerm]); + + // Reset selection when filtered items change + useEffect(() => { + if (filteredItems && filteredItems.length > 0 && !filteredItems.find(i => i.key === selectedKey)) { + setSelectedKey(filteredItems[0].key); + } + }, [filteredItems, selectedKey]); + + // Keyboard handler + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (!filteredItems) return; + + const currentIdx = filteredItems.findIndex(i => i.key === selectedKey); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = Math.min(currentIdx + 1, filteredItems.length - 1); + setSelectedKey(filteredItems[next].key); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = Math.max(currentIdx - 1, 0); + setSelectedKey(filteredItems[prev].key); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + const selected = filteredItems.find(i => i.key === selectedKey); + if (selected?.canDrillIn) { + setPath(prev => [...prev, selected.key]); + setSelectedKey(null); + } + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (path.length > 0) { + const parentKey = path[path.length - 1]; + setPath(prev => prev.slice(0, -1)); + setSelectedKey(parentKey); + } else { + onExit?.(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + if (path.length > 0) { + setPath([]); + setSelectedKey(null); + } else { + onExit?.(); + } + } else if (e.key === 'Home') { + e.preventDefault(); + if (filteredItems.length > 0) setSelectedKey(filteredItems[0].key); + } else if (e.key === 'End') { + e.preventDefault(); + if (filteredItems.length > 0) setSelectedKey(filteredItems[filteredItems.length - 1].key); + } + }; + el.addEventListener('keydown', handleKeyDown); + return () => el.removeEventListener('keydown', handleKeyDown); + }, [filteredItems, selectedKey, path, onExit]); + + // Keep scroll in sync with selection + useEffect(() => { + const container = containerRef.current; + if (!container || !selectedKey) return; + const el = container.querySelector(`[data-nav-key="${selectedKey}"]`); + el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [selectedKey]); + + // For the detail pane, use unfiltered navItems so we always find it + const selectedItem = navItems?.find(i => i.key === selectedKey) ?? null; + + // If data is a primitive, show it directly + if (parsedData == null || typeof parsedData !== 'object') { + return ( +
+
+
+                        {String(parsedData)}
+                    
+
+
+ ); + } + + return ( +
+ {/* Search bar */} +
+ + setSearchTerm(e.target.value)} + /> +
+ + {/* Breadcrumb */} +
+ + {path.map((seg, i) => ( + + + + + ))} +
+ + โ† โ†’ โ†‘ โ†“ to navigate + +
+ + {/* Body: items list + detail pane */} +
+ {/* Items list */} +
setSelectedKey(null)} + > + {filteredItems && filteredItems.length > 0 ? ( +
+ {filteredItems.map(item => ( + setSelectedKey(item.key)} + onDrillIn={() => { + setPath(prev => [...prev, item.key]); + setSelectedKey(null); + }} + /> + ))} +
+ ) : ( +
+ +

{searchTerm ? 'No matches' : 'Empty object'}

+
+ )} +
+ + {/* Detail sidebar */} +
+
+ Inspector +
+
+ +
+
+
+
+ ); +}; + +export default JSONTreeWalker; diff --git a/packages/ui/src/components/json/README.md b/packages/ui/src/components/json/README.md new file mode 100644 index 00000000..730d0e4a --- /dev/null +++ b/packages/ui/src/components/json/README.md @@ -0,0 +1,46 @@ +# JSONTreeWalker + +Keyboard-driven JSON inspector. View-only โ€” no editing. + +## Usage + +```tsx +import { JSONTreeWalker } from '@/components/json'; + +// Basic โ€” pass any JSON-serializable value + + +// With exit callback (e.g. return to parent view) + setShowWalker(false)} +/> + +// Styled + +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `data` | `any` | โ€” | JSON data to inspect. Accepts parsed objects/arrays or JSON strings (auto-parsed). | +| `rootLabel` | `string` | `"Root"` | Label shown at the breadcrumb root. | +| `className` | `string` | โ€” | Extra CSS on the outer wrapper. | +| `onExit` | `() => void` | โ€” | Fired on Escape / ArrowLeft at the top level. | + +## Keyboard + +| Key | Action | +|-----|--------| +| `โ†‘` / `โ†“` | Select prev / next item | +| `โ†’` | Drill into selected object/array | +| `โ†` | Go up one level (or exit at root) | +| `Home` / `End` | Jump to first / last item | +| `Escape` | Jump to root (or exit) | + +The filter input at the top matches against keys and display values. diff --git a/packages/ui/src/components/json/index.ts b/packages/ui/src/components/json/index.ts new file mode 100644 index 00000000..d7646973 --- /dev/null +++ b/packages/ui/src/components/json/index.ts @@ -0,0 +1,2 @@ +export { JSONTreeWalker } from './JSONTreeWalker'; +export type { JSONTreeWalkerProps, NavDisplayItem } from './JSONTreeWalker'; diff --git a/packages/ui/src/components/layouts/SnippetPropertyPanel.tsx b/packages/ui/src/components/layouts/SnippetPropertyPanel.tsx new file mode 100644 index 00000000..68e4c869 --- /dev/null +++ b/packages/ui/src/components/layouts/SnippetPropertyPanel.tsx @@ -0,0 +1,158 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Database } from "@/integrations/supabase/types"; +import { T, translate } from "@/i18n"; +import { toast } from "sonner"; +import { Play, Trash2, X } from "lucide-react"; +import { widgetRegistry } from "@/lib/widgetRegistry"; +import { WidgetPropertiesForm } from "@/components/widgets/WidgetPropertiesForm"; +import type { WidgetSnippetData } from "@/modules/layout/useWidgetSnippets"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type Layout = Database['public']['Tables']['layouts']['Row']; + +interface SnippetPropertyPanelProps { + snippet: Layout; + onInsert: (widgetId: string, props?: Record) => void; + onDelete: (id: string) => void; + onRename: (id: string, name: string) => void; + onPropsUpdate: (id: string, data: WidgetSnippetData) => void; + onClose: () => void; +} + +export const SnippetPropertyPanel = ({ + snippet, + onInsert, + onDelete, + onRename, + onPropsUpdate, + onClose, +}: SnippetPropertyPanelProps) => { + const data = snippet.layout_json as unknown as WidgetSnippetData; + const widgetDef = data?.widgetId ? widgetRegistry.get(data.widgetId) : null; + + const [name, setName] = useState(snippet.name); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [localProps, setLocalProps] = useState>(data?.props || {}); + + useEffect(() => { + setName(snippet.name); + const d = snippet.layout_json as unknown as WidgetSnippetData; + setLocalProps(d?.props || {}); + }, [snippet.id, snippet.name, snippet.layout_json]); + + const handleRename = () => { + const trimmed = name.trim(); + if (trimmed && trimmed !== snippet.name) { + onRename(snippet.id, trimmed); + } + }; + + const handleSettingsChange = (newSettings: Record) => { + setLocalProps(newSettings); + onPropsUpdate(snippet.id, { widgetId: data.widgetId, props: newSettings }); + }; + + return ( +
+
+

Snippet Properties

+ +
+ +
+ {/* Name Section */} +
+ + setName(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => e.key === 'Enter' && handleRename()} + /> +
+ + {/* Info */} +
+
+ Widget + {widgetDef?.metadata?.name || data?.widgetId || 'โ€”'} + + Created + + {new Date(snippet.created_at).toLocaleDateString()} + +
+
+ + {/* Widget Config Form */} + {widgetDef?.metadata?.configSchema && ( +
+ + +
+ )} +
+ + {/* Actions Footer */} +
+ + + +
+ + + + + Delete Snippet + + Are you sure you want to delete {snippet.name}? + + + + Cancel + onDelete(snippet.id)} + > + Delete + + + + +
+ ); +}; diff --git a/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx index a8364eea..2ab36a9d 100644 --- a/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx @@ -36,7 +36,7 @@ const AIImagePromptPopupWrapper = () => { ); }, [editor]); - const handleGenerate = async (prompt: string, provider: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none', referenceImages?: string[], applicationMode: 'replace' | 'insert' | 'append' = 'insert', resolution?: string, searchGrounding?: boolean) => { + const handleGenerate = async (prompt: string, provider: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none', referenceImages?: string[], applicationMode: 'replace' | 'insert' | 'append' = 'insert', resolution?: string, searchGrounding?: boolean, imageSearch?: boolean) => { const modelString = `${provider}/${model}`; if (!user?.id) { toast.error("You must be logged in to generate images."); @@ -88,7 +88,8 @@ const AIImagePromptPopupWrapper = () => { undefined, // apiKey aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } else { @@ -99,7 +100,8 @@ const AIImagePromptPopupWrapper = () => { undefined, // apiKey handled internally/server-side usually or locally aspectRatio, resolution, - searchGrounding + searchGrounding, + imageSearch ); } diff --git a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx index db245d67..dd2e70e0 100644 --- a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx +++ b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx @@ -6,9 +6,8 @@ import { T, translate } from '@/i18n'; import { Card } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { ModelSelector } from '@/components/ImageWizard/components/ModelSelector'; +import { ModelSelector } from '@/modules/ai/images/components'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { ProviderSelector } from '@/components/filters/ProviderSelector'; import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; import { Switch } from '@/components/ui/switch'; import { Plus } from 'lucide-react'; @@ -16,7 +15,7 @@ import { Plus } from 'lucide-react'; interface AIImagePromptPopupProps { isOpen: boolean; onClose: () => void; - onGenerate: (prompt: string, provider: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none', referenceImages?: string[], applicationMode?: 'replace' | 'insert' | 'append', resolution?: string, searchGrounding?: boolean) => Promise; + onGenerate: (prompt: string, provider: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none', referenceImages?: string[], applicationMode?: 'replace' | 'insert' | 'append', resolution?: string, searchGrounding?: boolean, imageSearch?: boolean) => Promise; initialProvider?: string; initialModel?: string; hasSelection: boolean; @@ -62,6 +61,10 @@ export const AIImagePromptPopup: React.FC = ({ return localStorage.getItem('ai_image_last_grounding') === 'true'; }); + const [imageSearch, setImageSearch] = useState(() => { + return localStorage.getItem('ai_image_last_image_search') === 'true'; + }); + const [isGenerating, setIsGenerating] = useState(false); const [contextMode, setContextMode] = useState<'selection' | 'content' | 'none'>('none'); const [applicationMode, setApplicationMode] = useState<'replace' | 'insert' | 'append'>('append'); @@ -92,6 +95,10 @@ export const AIImagePromptPopup: React.FC = ({ localStorage.setItem('ai_image_last_grounding', String(searchGrounding)); }, [searchGrounding]); + useEffect(() => { + localStorage.setItem('ai_image_last_image_search', String(imageSearch)); + }, [imageSearch]); + // Initial context mode selection useEffect(() => { if (isOpen) { @@ -170,7 +177,8 @@ export const AIImagePromptPopup: React.FC = ({ referenceImages.map(img => img.image_url || img.src), applicationMode, resolution, - searchGrounding + searchGrounding, + imageSearch ); onClose(); setPrompt(''); @@ -363,6 +371,26 @@ export const AIImagePromptPopup: React.FC = ({
)} + {/* Google Image Search Grounding */} + {isGoogleModel && ( +
+ + { + setImageSearch(checked); + // Auto-enable search grounding if image search is enabled + if (checked) setSearchGrounding(true); + }} + disabled={isGenerating} + className="scale-75 origin-right" + /> +
+ )} + {/* Reference Images Section */}
diff --git a/packages/ui/src/components/lazy-editors/LinkPlugin.tsx b/packages/ui/src/components/lazy-editors/LinkPlugin.tsx new file mode 100644 index 00000000..29b57e10 --- /dev/null +++ b/packages/ui/src/components/lazy-editors/LinkPlugin.tsx @@ -0,0 +1,627 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getSelection, $isRangeSelection, $setSelection, $getRoot, createCommand, LexicalCommand, COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, RangeSelection } from 'lexical'; +import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; +import { mergeRegister } from '@lexical/utils'; +import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@mdxeditor/editor'; +import { Link, FileText, ExternalLink, Trash2, ImageIcon, FolderOpen } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { T, translate } from '@/i18n'; +import { PagePickerDialog } from '@/modules/pages/PagePickerDialog'; +import { Page } from '@/modules/pages/types'; +import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog'; +import { FileBrowserWidget } from '@/modules/storage/FileBrowserWidget'; +import { vfsUrl } from '@/modules/storage/helpers'; +import type { INode } from '@/modules/storage/types'; +import { useAuth } from '@/hooks/useAuth'; + +export const OPEN_LINK_EDITOR_COMMAND: LexicalCommand = createCommand('OPEN_LINK_EDITOR_COMMAND'); + +// โ”€โ”€ Dialog-based link editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function LinkEditorComponent() { + const [editor] = useLexicalComposerContext(); + const { user } = useAuth(); + + const [isOpen, setIsOpen] = useState(false); + const [url, setUrl] = useState(''); + const [title, setTitle] = useState(''); + const [selectedText, setSelectedText] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const [pagePickerOpen, setPagePickerOpen] = useState(false); + const [imagePickerOpen, setImagePickerOpen] = useState(false); + const [filePickerOpen, setFilePickerOpen] = useState(false); + const [filePickerMount, setFilePickerMount] = useState('home'); + const [filePickerPath, setFilePickerPath] = useState('/'); + const [folderLayout, setFolderLayout] = useState<'list' | 'thumbs'>('list'); + const [folderToolbar, setFolderToolbar] = useState(true); + + // Re-apply URL params when folder display options change + useEffect(() => { + if (!url.startsWith('/app/filebrowser/')) return; + const base = url.split('?')[0]; + const params = new URLSearchParams(); + if (folderLayout !== 'list') params.set('view', folderLayout); + if (!folderToolbar) params.set('toolbar', '0'); + const qs = params.toString(); + setUrl(qs ? `${base}?${qs}` : base); + }, [folderLayout, folderToolbar]); + + const urlInputRef = useRef(null); + const savedSelectionRef = useRef(null); + const insertMarkdown = usePublisher(insertMarkdown$); + + /** Safe link insertion: tries to restore selection + TOGGLE_LINK, falls back to insertMarkdown */ + const safeInsertLink = useCallback((linkUrl: string, linkTitle: string) => { + let selectionRestored = false; + if (savedSelectionRef.current) { + try { + editor.update(() => { + $setSelection(savedSelectionRef.current!.clone()); + }); + selectionRestored = true; + } catch { + // Stale node โ€” skip, insertMarkdown path will handle it + } + } + + if (selectedText && selectionRestored) { + setTimeout(() => { + try { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, { + url: linkUrl, + title: linkTitle || undefined, + }); + } catch { /* stale selection fallback */ } + }, 0); + } else { + // Ensure a valid cursor exists before inserting markdown + try { + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastDescendant(); + if (lastChild) { + lastChild.selectEnd(); + } else { + root.selectEnd(); + } + }); + } catch { /* worst case, insertMarkdown may still work */ } + setTimeout(() => { + insertMarkdown(`[${linkTitle || linkUrl}](${linkUrl})`); + }, 0); + } + }, [editor, selectedText, insertMarkdown]); + + const openDialog = useCallback(() => { + try { + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) return; + + // Save the selection for later restoration + savedSelectionRef.current = selection.clone() as RangeSelection; + + const text = selection.getTextContent(); + setSelectedText(text); + + // Check if we're inside an existing link + const nodes = selection.getNodes(); + let existingUrl = ''; + let existingTitle = ''; + let foundLink = false; + + for (const node of nodes) { + const parent = node.getParent(); + if ($isLinkNode(parent)) { + existingUrl = parent.getURL(); + existingTitle = parent.getTitle() || ''; + foundLink = true; + break; + } + } + + setIsEditing(foundLink); + setUrl(existingUrl); + setTitle(existingTitle || text); + }); + } catch { + // Stale state (e.g., slash command removed trigger node) + savedSelectionRef.current = null; + setSelectedText(''); + setIsEditing(false); + setUrl(''); + setTitle(''); + } + + setIsOpen(true); + setTimeout(() => urlInputRef.current?.focus(), 150); + }, [editor]); + + // Register Ctrl+K shortcut and command + useEffect(() => { + return mergeRegister( + editor.registerCommand( + KEY_DOWN_COMMAND, + (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'k') { + event.preventDefault(); + openDialog(); + return true; + } + return false; + }, + COMMAND_PRIORITY_NORMAL + ), + editor.registerCommand( + OPEN_LINK_EDITOR_COMMAND, + () => { + openDialog(); + return true; + }, + COMMAND_PRIORITY_NORMAL + ) + ); + }, [editor, openDialog]); + + const handleSave = useCallback(() => { + if (!url.trim()) return; + + const linkUrl = url.trim(); + const linkTitle = title.trim() || ''; + + safeInsertLink(linkUrl, linkTitle); + + setIsOpen(false); + setUrl(''); + setTitle(''); + savedSelectionRef.current = null; + try { editor.focus(); } catch { } + }, [editor, url, title, safeInsertLink]); + + const handleRemoveLink = useCallback(() => { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + setIsOpen(false); + setUrl(''); + setTitle(''); + try { editor.focus(); } catch { } + }, [editor]); + + const handleClose = useCallback(() => { + setIsOpen(false); + setUrl(''); + setTitle(''); + try { editor.focus(); } catch { } + }, [editor]); + + const handlePageSelect = useCallback((page: Page | null) => { + if (page) { + const ownerId = page.owner || user?.id || ''; + const linkUrl = `/user/${ownerId}/pages/${page.slug}`; + const linkTitle = page.title; + + safeInsertLink(linkUrl, linkTitle); + + setIsOpen(false); + setUrl(''); + setTitle(''); + savedSelectionRef.current = null; + try { editor.focus(); } catch { } + } + setPagePickerOpen(false); + }, [editor, user, safeInsertLink]); + + const handleImageSelect = useCallback((pictureId: string) => { + // We won't use this - we use onSelectPicture instead + }, []); + + const handleImageSelectPicture = useCallback((picture: { id: string; title: string; image_url: string }) => { + const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || ''; + const linkUrl = serverUrl + ? `${serverUrl}/api/images/render?url=${encodeURIComponent(picture.image_url)}` + : picture.image_url; + const linkTitle = picture.title || 'Image'; + + safeInsertLink(linkUrl, linkTitle); + + setIsOpen(false); + setUrl(''); + setTitle(''); + savedSelectionRef.current = null; + setImagePickerOpen(false); + try { editor.focus(); } catch { } + }, [editor, safeInsertLink]); + + const handleFileSelect = useCallback((filePath: string | null) => { + if (!filePath) return; + const linkUrl = vfsUrl('get', filePickerMount, filePath); + const fileName = filePath.split('/').pop() || filePath; + + safeInsertLink(linkUrl, fileName); + + setIsOpen(false); + setUrl(''); + setTitle(''); + savedSelectionRef.current = null; + setFilePickerOpen(false); + try { editor.focus(); } catch { } + }, [editor, safeInsertLink, filePickerMount]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } + if (e.key === 'Escape') { + e.preventDefault(); + handleClose(); + } + }, [handleSave, handleClose]); + + const buildPageUrl = useCallback((slug: string) => { + const ownerId = user?.id || ''; + return `/user/${ownerId}/pages/${slug}`; + }, [user]); + + return ( + <> + { if (!open) handleClose(); }}> + { if (pagePickerOpen || imagePickerOpen || filePickerOpen) e.preventDefault(); }}> + + + {isEditing ? Edit Link : Insert Link} + + + {selectedText ? ( + <>Link text: "{selectedText}" + ) : ( + Add a link to the selected text. + )} + + + + + + + + URL + + + + Page + + + + Image + + + + File + + + + +
+ + setUrl(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="https://..." + autoFocus + /> +
+
+ + setTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate('Link title...')} + /> +
+
+ + +
+ +
+ setUrl(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={buildPageUrl('page-slug')} + className="flex-1" + /> + +
+
+
+ + setTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate('Link title...')} + /> +
+
+ + +
+ +
+ setUrl(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate('Image URL...')} + className="flex-1" + /> + +
+
+
+ + setTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate('Link title...')} + /> +
+
+ + +
+ +
+ setUrl(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="/api/vfs/get/home/..." + className="flex-1" + /> + +
+
+
+ + setTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={translate('Link title...')} + /> +
+
+
+ + +
+
+ setFolderToolbar(!!v)} + /> + +
+
+
+
+ +
+ {isEditing ? ( + + ) : ( +
+ )} +
+ + +
+
+ +
+ + setPagePickerOpen(false)} + onSelect={handlePageSelect} + /> + + setImagePickerOpen(false)} + onSelect={handleImageSelect} + onSelectPicture={handleImageSelectPicture} + /> + + { if (!open) setFilePickerOpen(false); }}> + + + Select File + + Browse your files to create a download link + + +
+ { + if (node) { + const isDir = node.type === 'dir' || node.mime === 'inode/directory'; + const nodeName = node.name || node.path.split('/').pop() || node.path; + if (isDir) { + // Folder โ†’ use app route with display options + const cleanPath = node.path.replace(/^\/+/, ''); + let folderUrl = cleanPath + ? `/app/filebrowser/${filePickerMount}/${cleanPath}` + : `/app/filebrowser/${filePickerMount}`; + const params = new URLSearchParams(); + if (folderLayout !== 'list') params.set('view', folderLayout); + if (!folderToolbar) params.set('toolbar', '0'); + const qs = params.toString(); + if (qs) folderUrl += `?${qs}`; + setUrl(folderUrl); + } else { + // File โ†’ use VFS API download URL + setUrl(vfsUrl('get', filePickerMount, node.path)); + } + setTitle(nodeName); + } + }} + onMountChange={(m) => { + setFilePickerMount(m); + setFilePickerPath('/'); + }} + onPathChange={(p) => setFilePickerPath(p)} + /> +
+
+ +
+ + +
+
+
+
+ + ); +} + +// โ”€โ”€ Toolbar button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function InsertLinkToolbarButton() { + const [editor] = useLexicalComposerContext(); + + return ( + + ); +} + +// โ”€โ”€ Plugin factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const linkEditorPlugin = (): RealmPlugin => { + return { + init: (realm) => { + realm.pubIn({ + [addComposerChild$]: () => + }); + } + }; +}; diff --git a/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx b/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx index 2c63ce51..c3f279c8 100644 --- a/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx +++ b/packages/ui/src/components/lazy-editors/MDXEditorInternal.tsx @@ -1,10 +1,10 @@ import React, { useEffect } from 'react'; import { MDXEditor, headingsPlugin, linkPlugin, - linkDialogPlugin, diffSourcePlugin, toolbarPlugin, imagePlugin, + diffSourcePlugin, toolbarPlugin, imagePlugin, quotePlugin, thematicBreakPlugin, tablePlugin, UndoRedo, BoldItalicUnderlineToggles, - DiffSourceToggleWrapper, BlockTypeSelect, CreateLink, InsertThematicBreak, + DiffSourceToggleWrapper, BlockTypeSelect, InsertThematicBreak, InsertTable, ListsToggle, listsPlugin, codeBlockPlugin, codeMirrorPlugin, sandpackPlugin, SandpackConfig } from '@mdxeditor/editor'; @@ -16,6 +16,7 @@ import { useState } from 'react'; import { slashCommandPlugin } from './SlashCommandPlugin'; import { aiGenerationPlugin } from './AIGenerationPlugin'; import { aiImageGenerationPlugin } from './AIImageGenerationPlugin'; +import { linkEditorPlugin, InsertLinkToolbarButton } from './LinkPlugin'; export interface MDXEditorWithImagePickerProps { value: string; @@ -114,14 +115,23 @@ export default function MDXEditorInternal({ const allPlugins = React.useMemo(() => [ headingsPlugin(), linkPlugin(), - linkDialogPlugin(), + linkEditorPlugin(), imagePlugin(), quotePlugin(), thematicBreakPlugin(), listsPlugin(), tablePlugin(), + diffSourcePlugin(), codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }), - codeMirrorPlugin({ codeBlockLanguages: { js: 'JavaScript', css: 'CSS', 'json': 'JSON', '': 'Text' } }), + codeMirrorPlugin({ + codeBlockLanguages: { + js: 'JavaScript', + css: 'CSS', + 'json': 'JSON', + 'custom-gallery': 'JSON', + '': 'Text' + } + }), sandpackPlugin({ sandpackConfig: simpleSandpackConfig }), slashCommandPlugin({ onRequestImage }), aiGenerationPlugin(), @@ -133,9 +143,14 @@ export default function MDXEditorInternal({ - + + + + + <> + ) }) diff --git a/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx b/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx index db8096a7..a7e52684 100644 --- a/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx +++ b/packages/ui/src/components/lazy-editors/SlashCommandPlugin.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'; -import { TextNode, $createParagraphNode, $getSelection, $isRangeSelection, FORMAT_ELEMENT_COMMAND } from 'lexical'; +import { TextNode, $createParagraphNode, $getSelection, $isRangeSelection, FORMAT_ELEMENT_COMMAND, $getRoot } from 'lexical'; import { $createHeadingNode, $createQuoteNode } from '@lexical/rich-text'; import { INSERT_UNORDERED_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_CHECK_LIST_COMMAND } from '@lexical/list'; import { INSERT_TABLE_COMMAND } from '@lexical/table'; @@ -17,13 +17,16 @@ import { Image, Code, Sparkles, - Bot + Bot, + Link, + FolderOpen } from 'lucide-react'; import * as ReactDOM from 'react-dom'; import { OPEN_IMAGE_GEN_COMMAND } from './AIImageGenerationPlugin'; import { OPEN_AI_TEXT_GEN_COMMAND } from './AIGenerationPlugin'; +import { OPEN_LINK_EDITOR_COMMAND } from './LinkPlugin'; -// Import from MDXEditor to hook into its plugin system and access ImageNode creation +// Import from MDXEditor to hook into its plugin system import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor'; import { $insertNodes } from 'lexical'; @@ -113,17 +116,38 @@ function SlashCommandMenu({ onRequestImage().then(url => { if (url) { editor.update(() => { - const imageNode = $createImageNode({ - src: url, - altText: 'image', - title: 'image' - }); - $insertNodes([imageNode]); + try { + const imageNode = $createImageNode({ + src: url, + altText: 'image', + title: 'image' + }); + $insertNodes([imageNode]); + } catch (e) { + // Selection lost during async dialog โ€” append to end + console.warn('[SlashCommand] $insertNodes failed, appending to root:', e); + const imageNode = $createImageNode({ + src: url, + altText: 'image', + title: 'image' + }); + const paragraph = $createParagraphNode(); + paragraph.append(imageNode); + $getRoot().append(paragraph); + } }); } + }).catch((err) => { + if (err) console.warn('[SlashCommand] Image request failed:', err); }); }) - ] : []) + ] : []), + new SlashCommandOption('Link', , ['link', 'url', 'href'], () => { + editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined); + }), + new SlashCommandOption('File Link', , ['file', 'download', 'vfs', 'browse'], () => { + editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined); + }), ]; // Helper from Lexical to change block type @@ -197,17 +221,22 @@ function SlashCommandMenu({ return null; } - return anchorElementRef.current && ReactDOM.createPortal( -
+ // Compute position from the anchor element's bounding rect + const rect = anchorElementRef.current.getBoundingClientRect(); + + return ReactDOM.createPortal( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + >
{options.map((option, i) => { - // Filter manually if the plugin doesn't do it for us (it usually does but we want custom fuzzy logic maybe) - // Actually LexicalTypeaheadMenuPlugin takes `options` and we filter inside `options` prop usually? - // No, the plugin passes all options to renderFn usually? - // Wait, LexicalTypeaheadMenuPlugin expects *filtered* options passed to it? - // Let's double check docs mentally. - // Usually we filter in parent and pass filtered to 'options'. - // Let's filter here for safety. if (queryString && !optionsFilter(option, queryString)) return null; return ( @@ -231,7 +260,7 @@ function SlashCommandMenu({ })}
, - anchorElementRef.current + document.body ); }} /> diff --git a/packages/ui/src/components/playground/I18nPlayground.tsx b/packages/ui/src/components/playground/I18nPlayground.tsx index 67176fe3..40249506 100644 --- a/packages/ui/src/components/playground/I18nPlayground.tsx +++ b/packages/ui/src/components/playground/I18nPlayground.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { translateText, fetchGlossaries, createGlossary, deleteGlossary, fetchGlossaryTerms, updateGlossaryTerms, @@ -35,6 +35,7 @@ export default function I18nPlayground() { const [translation, setTranslation] = useState(''); const [selectedGlossaryId, setSelectedGlossaryId] = useState(''); const [isTranslating, setIsTranslating] = useState(false); + const [markdownMode, setMarkdownMode] = useState(false); // Glossary State const [glossaries, setGlossaries] = useState([]); @@ -70,7 +71,7 @@ export default function I18nPlayground() { loadGlossaries(); }, []); - const loadGlossaries = async () => { + const loadGlossaries = useCallback(async () => { setLoadingGlossaries(true); try { const data = await fetchGlossaries(); @@ -80,13 +81,13 @@ export default function I18nPlayground() { } finally { setLoadingGlossaries(false); } - }; + }, []); const handleTranslate = async () => { if (!text) return; setIsTranslating(true); try { - const res = await translateText(text, srcLang, dstLang, selectedGlossaryId === 'none' ? undefined : selectedGlossaryId); + const res = await translateText(text, srcLang, dstLang, selectedGlossaryId === 'none' ? undefined : selectedGlossaryId, markdownMode ? 'markdown' : 'text'); setTranslation(res.translation); } catch (e) { console.error(e); @@ -185,12 +186,12 @@ export default function I18nPlayground() { }); }; - const glossaryTermsDirty = (() => { + const glossaryTermsDirty = useMemo(() => { const keys = Object.keys(glossaryTerms); const origKeys = Object.keys(glossaryTermsOriginal); if (keys.length !== origKeys.length) return true; return keys.some(k => glossaryTerms[k] !== glossaryTermsOriginal[k]); - })(); + }, [glossaryTerms, glossaryTermsOriginal]); const handleSaveGlossaryTerms = async () => { if (!expandedGlossaryId) return; @@ -274,7 +275,7 @@ export default function I18nPlayground() { } }; - const handleAddToGlossary = async (item: WidgetTranslation) => { + const handleAddToGlossary = useCallback(async (item: WidgetTranslation) => { if (!item.source_text || !item.translated_text) return; const srcLangUpper = (item.source_lang || 'en').toUpperCase(); @@ -306,7 +307,7 @@ export default function I18nPlayground() { } catch (e: any) { toast.error(`Failed to add to glossary: ${e.message}`); } - }; + }, [glossaries, srcLangDefault, loadGlossaries]); return (
@@ -348,10 +349,13 @@ export default function I18nPlayground() { None {glossaries - .filter(g => - (g.source_lang === srcLang && g.target_lang === dstLang) || - (g.source_lang === dstLang && g.target_lang === srcLang) - ) + .filter(g => { + const gs = g.source_lang.toLowerCase(); + const gt = g.target_lang.toLowerCase(); + const sl = srcLang.toLowerCase(); + const dl = dstLang.toLowerCase(); + return (gs === sl && gt === dl) || (gs === dl && gt === sl); + }) .map(g => ( {g.name} ({g.source_lang}โ†’{g.target_lang}, {g.entry_count} entries) @@ -362,21 +366,34 @@ export default function I18nPlayground() {