From 56564ad1e593617c96aa4623b541dfe38479878e Mon Sep 17 00:00:00 2001 From: Babayaga Date: Wed, 25 Feb 2026 10:11:54 +0100 Subject: [PATCH] flex container 1/2 --- packages/ui/docs/cache-ex.md | 175 +++++ packages/ui/docs/i18n.md | 175 ++++- packages/ui/docs/layoutcontainer-ex.md | 642 +++++++++++++++++ packages/ui/docs/testing-client.md | 4 +- packages/ui/shared/package-lock.json | 16 +- packages/ui/shared/package.json | 2 +- packages/ui/shared/src/config/config.d.ts | 220 ++++++ .../ui/shared/src/config/config.schema.ts | 225 ++++++ packages/ui/shared/src/ui/schemas.ts | 16 +- packages/ui/src/App.tsx | 24 +- .../ui/src/components/AITextGenerator.tsx | 11 +- .../ui/src/components/CategoryTreeView.tsx | 21 +- .../ui/src/components/CreationWizardPopup.tsx | 3 +- packages/ui/src/components/EditImageModal.tsx | 23 +- packages/ui/src/components/Footer.tsx | 86 +++ packages/ui/src/components/GalleryLarge.tsx | 51 +- packages/ui/src/components/Header.tsx | 110 --- packages/ui/src/components/HeroSection.tsx | 117 ---- packages/ui/src/components/ImageGallery.tsx | 289 ++++---- packages/ui/src/components/ImageWizard.tsx | 4 +- .../ImageWizard/components/ModelSelector.tsx | 1 + packages/ui/src/components/ListLayout.tsx | 32 +- packages/ui/src/components/PhotoCard.tsx | 228 +++--- .../ui/src/components/StreamInvalidator.tsx | 83 ++- packages/ui/src/components/TopNavigation.tsx | 24 +- packages/ui/src/components/UploadModal.tsx | 212 ------ packages/ui/src/components/VideoCard.tsx | 6 +- .../ui/src/components/admin/AclEditor.tsx | 53 +- .../ui/src/components/admin/BansManager.tsx | 53 +- .../src/components/admin/StorageManager.tsx | 7 +- .../ui/src/components/admin/UserManager.tsx | 14 +- .../components/admin/ViolationsMonitor.tsx | 42 +- .../components/filters/ProviderSelector.tsx | 7 +- .../lazy-editors/AIGenerationPlugin.tsx | 2 +- .../lazy-editors/MDXEditorInternal.tsx | 2 +- .../components/playground/I18nPlayground.tsx | 463 +++++++++++- .../components/variables/VariableBuilder.tsx | 59 +- .../components/variables/VariablesEditor.tsx | 7 +- .../components/widgets/CategoryManager.tsx | 117 ++-- .../ui/src/components/widgets/HomeWidget.tsx | 246 +++++++ .../widgets/MarkdownTextWidget-Edit.tsx | 2 +- .../components/widgets/MarkdownTextWidget.tsx | 46 +- .../components/widgets/PhotoCardWidget.tsx | 12 + .../widgets/WidgetPropertiesForm.tsx | 47 +- packages/ui/src/hooks/useAuth.tsx | 24 + packages/ui/src/hooks/useFeedData.ts | 3 +- packages/ui/src/hooks/useSystemInfo.ts | 9 +- packages/ui/src/i18n.tsx | 103 ++- packages/ui/src/i18n/de.json | 663 +++++++++++++----- packages/ui/src/i18n/zh.json | 146 ++++ packages/ui/src/lib/db.ts | 2 + packages/ui/src/lib/registerWidgets.ts | 150 +++- packages/ui/src/lib/widgetRegistry.ts | 2 +- .../modules/categories/client-categories.ts | 10 +- .../i18n/CategoryTranslationDialog.tsx | 61 ++ .../modules/i18n/PageTranslationDialog.tsx | 473 +++++++++++++ .../ui/src/modules/i18n/TranslationsGrid.tsx | 340 +++++++++ .../modules/i18n/TypeTranslationDialog.tsx | 93 +++ .../modules/i18n/WidgetTranslationPanel.tsx | 496 +++++++++++++ packages/ui/src/modules/i18n/client-i18n.ts | 151 ++++ .../src/modules/i18n/useWidgetTranslation.ts | 379 ++++++++++ .../ui/src/modules/layout/LayoutContainer.tsx | 67 +- .../ui/src/modules/layout/client-layouts.ts | 83 ++- packages/ui/src/modules/layout/commands.ts | 72 ++ packages/ui/src/modules/pages/NewPage.tsx | 5 +- packages/ui/src/modules/pages/PageActions.tsx | 32 +- packages/ui/src/modules/pages/PageCard.tsx | 2 +- packages/ui/src/modules/pages/UserPage.tsx | 63 +- packages/ui/src/modules/pages/client-pages.ts | 5 +- .../modules/pages/editor/AILayoutWizard.tsx | 7 +- .../pages/editor/EmailPreviewPanel.tsx | 4 +- .../modules/pages/editor/SendEmailDialog.tsx | 64 +- .../src/modules/pages/editor/UserPageEdit.tsx | 4 + .../pages/editor/UserPageTypeFields.tsx | 7 +- .../pages/editor/hooks/useClipboardActions.ts | 24 +- .../hooks/useEditorKeyboardShortcuts.ts | 6 +- .../pages/editor/hooks/useEmailActions.ts | 17 +- .../pages/editor/hooks/useTemplateManager.ts | 1 + .../pages/editor/ribbons/PageRibbonBar.tsx | 118 +++- .../ui/src/modules/posts/client-pictures.ts | 47 +- packages/ui/src/modules/posts/client-posts.ts | 3 + .../src/modules/types/TypeEditorActions.tsx | 2 +- packages/ui/src/modules/types/TypesEditor.tsx | 66 +- .../ui/src/modules/types/TypesPlayground.tsx | 8 +- packages/ui/src/pages/AdminPage.tsx | 42 +- packages/ui/src/pages/Auth.tsx | 269 ++++--- packages/ui/src/pages/Index.tsx | 15 +- .../pages/Post/renderers/ArticleRenderer.tsx | 25 +- .../pages/Post/renderers/EmbedRenderer.tsx | 27 +- .../components/CompactMediaViewer.tsx | 38 +- .../renderers/components/MobileGroupItem.tsx | 22 +- packages/ui/src/pages/Profile.tsx | 47 +- packages/ui/src/pages/ProviderSettings.tsx | 8 +- packages/ui/src/pages/UpdatePassword.tsx | 101 +++ .../ui/src/player/components/VideoPlayer.tsx | 3 +- .../player/components/VidstackPlayerImpl.tsx | 3 +- packages/ui/src/styles/mdx-editor-theme.css | 5 + packages/ui/src/sw.ts | 57 +- packages/ui/src/types-server.ts | 2 + 99 files changed, 6765 insertions(+), 1690 deletions(-) create mode 100644 packages/ui/docs/cache-ex.md create mode 100644 packages/ui/docs/layoutcontainer-ex.md create mode 100644 packages/ui/shared/src/config/config.d.ts create mode 100644 packages/ui/shared/src/config/config.schema.ts create mode 100644 packages/ui/src/components/Footer.tsx delete mode 100644 packages/ui/src/components/Header.tsx delete mode 100644 packages/ui/src/components/HeroSection.tsx delete mode 100644 packages/ui/src/components/UploadModal.tsx create mode 100644 packages/ui/src/components/widgets/HomeWidget.tsx create mode 100644 packages/ui/src/i18n/zh.json create mode 100644 packages/ui/src/modules/i18n/CategoryTranslationDialog.tsx create mode 100644 packages/ui/src/modules/i18n/PageTranslationDialog.tsx create mode 100644 packages/ui/src/modules/i18n/TranslationsGrid.tsx create mode 100644 packages/ui/src/modules/i18n/TypeTranslationDialog.tsx create mode 100644 packages/ui/src/modules/i18n/WidgetTranslationPanel.tsx create mode 100644 packages/ui/src/modules/i18n/useWidgetTranslation.ts create mode 100644 packages/ui/src/pages/UpdatePassword.tsx diff --git a/packages/ui/docs/cache-ex.md b/packages/ui/docs/cache-ex.md new file mode 100644 index 00000000..e4ff4ac9 --- /dev/null +++ b/packages/ui/docs/cache-ex.md @@ -0,0 +1,175 @@ +# 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/i18n.md b/packages/ui/docs/i18n.md index 8f347467..ee5a3682 100644 --- a/packages/ui/docs/i18n.md +++ b/packages/ui/docs/i18n.md @@ -410,5 +410,178 @@ This keeps DeepL machine translations and human translations using the **same te |---|---|---| | **Phase 1** | Content versioning for pages | `content_versions` | | **Phase 2** | Page-level translations | `content_translations` | -| **Phase 3** | Widget-level translations | `widget_translations` | +| **Phase 3** | Widget-level translations | `widget_translations` ✅ | | **Phase 4** | Extend to posts / collections | Same tables, new `entity_type` values | + +--- + +## Implemented Features + +### Client i18n Loading (`src/i18n.tsx`) + +Translations are loaded from `src/i18n/*.json` using Vite's `import.meta.glob` with `eager: true`. This ensures: + +- All JSON files are statically included at build time +- Vite HMR pushes updates instantly when a JSON file changes on disk +- No stale module cache issues (unlike dynamic `import()`) + +```typescript +const langModules = import.meta.glob('./i18n/*.json', { eager: true }); +``` + +**Requested terms** (keys seen in the app but not yet translated) are cached in `localStorage` under `i18n-requested-terms`. These are merged with the loaded JSON translations, with JSON taking priority. + +--- + +### Glossary Term Editing (DeepL v3 API) + +#### API Endpoints + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/api/i18n/glossaries/:id/terms` | Fetch all terms for a glossary | +| `PUT` | `/api/i18n/glossaries/:id/terms` | Replace all terms (syncs with DeepL v3, updates DB, flushes cache) | + +The PUT endpoint uses the **DeepL v3 API** (`PUT /v3/glossaries/{id}/dictionaries`) to replace the entire glossary dictionary in TSV format. It then syncs the local DB (`i18n_glossary_terms`) and updates `entry_count`. + +#### Client Functions + +- `fetchGlossaryTerms(glossaryId)` — fetches term pairs as `Record` +- `updateGlossaryTerms(glossaryId, entries)` — replaces all terms + +#### Playground UI + +Glossaries in the management section are **expandable** — click to load and inline-edit terms. Each glossary row shows: +- Add/delete individual terms +- "Save" button (enabled only when there are unsaved changes via dirty-state detection) + +--- + +### Glossary Selection Improvements + +- **Bidirectional filter**: The glossary dropdown in the Translation section shows glossaries matching the language pair in **either direction** (e.g. when translating `en→de`, both `en→de` and `de→en` glossaries appear) +- **Direction label**: Each glossary option shows its direction: `osr (de→en, 2 entries)` +- **DeepL target lang normalization**: `en`/`EN` → `en-GB`, `pt`/`PT` → `pt-PT` (DeepL rejects bare `en`/`pt` target codes) + +--- + +### Widget Translations + +#### Schema (Actual — Deployed) + +```sql +CREATE TABLE widget_translations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type text NOT NULL DEFAULT 'page', + entity_id text, -- nullable for system translations + widget_id text, -- nullable for system translations + prop_path text NOT NULL DEFAULT 'content', + source_lang text NOT NULL, + target_lang text NOT NULL, + source_text text, + translated_text text, + source_version int, + status text DEFAULT 'draft', + meta jsonb DEFAULT '{}', + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + + CONSTRAINT uq_widget_translation + UNIQUE NULLS NOT DISTINCT (entity_type, entity_id, widget_id, prop_path, target_lang) +); +``` + +> Uses `NULLS NOT DISTINCT` so system translations (with NULL `entity_id`/`widget_id`) are still properly deduplicated. The unique constraint is required by PostgREST for upsert conflict resolution. + +#### API Endpoints + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/api/i18n/widget-translations` | Query with filters: `entity_type`, `entity_id`, `widget_id`, `target_lang` | +| `PUT` | `/api/i18n/widget-translations` | Upsert single translation | +| `PUT` | `/api/i18n/widget-translations/batch` | Upsert multiple translations | +| `DELETE` | `/api/i18n/widget-translations/:id` | Delete by ID | +| `DELETE` | `/api/i18n/widget-translations/entity/:type/:id` | Delete all translations for an entity (optional `?target_lang=`) | + +#### Client Functions + +- `fetchWidgetTranslations(filters)` — query with optional entity/widget/lang filters +- `upsertWidgetTranslation(input)` — upsert a single translation +- `upsertWidgetTranslationsBatch(inputs)` — upsert multiple (used by "Update Database") +- `deleteWidgetTranslation(id)` — delete by ID +- `deleteWidgetTranslationsByEntity(type, id, lang?)` — bulk delete + +--- + +### Update i18n Language Files + +#### API Endpoint + +| Method | Endpoint | Description | +|---|---|---| +| `PUT` | `/api/i18n/update-lang-file` | Merge translations into `src/i18n/{lang}.json` | + +**Request body**: `{ lang: string, entries: Record }` + +**Behavior**: +1. Reads `CLIENT_SRC_PATH` from server `.env` (set to `../`) +2. Resolves `${CLIENT_SRC_PATH}/src/i18n/${lang}.json` +3. Reads existing file, merges new entries (skips empty values) +4. Sorts alphabetically by key +5. Writes back with `JSON.stringify(sorted, null, 2)` +6. Returns `{ success, total, added, updated }` + +**Client function**: `updateLangFile(lang, entries)` + +--- + +### Playground UI — Widget Translations Section + +The i18n Playground (`/playground` → i18n tab) provides a full management UI: + +#### Search & Filter +- **Entity type / Entity ID / Widget ID / Target lang** — server-side filters for querying +- **Client-side search** — filter loaded results by source or translation text (case-insensitive) +- **Show missing** toggle — filter to untranslated entries only + +#### Row Selection +- **Checkbox per row** with **select-all** in header +- Selected rows get a subtle highlight +- Selection affects: batch translate, Update Database, and Update i18n + +#### Batch Translation +- **Glossary picker** — select a glossary for batch translation (shows all glossaries with direction labels) +- **Translate All Missing** / **Translate Selected** — batch-translates via DeepL +- Progress indicator during batch translation + +#### Persistence Actions +- 🟠 **Update Database** — batch-upserts translated entries to Supabase via `upsertWidgetTranslationsBatch` +- 🟢 **Update i18n** — merges translations into `src/i18n/{lang}.json` files (groups by `target_lang`, uses `source_text` as key) + +Both buttons respect checkbox selection: if rows are selected, only those are processed; otherwise all translated entries. + +#### Import from i18n +- **Import from app** — loads terms from `localStorage` requested-terms cache, cross-references with existing translations, and populates the list + +#### Inline Editing +- Click any row to expand and edit source text, translated text, status, and metadata +- Single-row translate button (DeepL) in edit mode + +--- + +### Environment Variables + +| Variable | Location | Value | Purpose | +|---|---|---|---| +| `CLIENT_SRC_PATH` | `server/.env` | `../` | Path to client source root (for writing `src/i18n/*.json`) | +| `CLIENT_DIST_PATH` | `server/.env` | `../dist` | Path to client build output | + +--- + +### E2E Tests (`i18n.e2e.test.ts`) + +Tests cover: +- Glossary CRUD (create, list, get terms, update terms via DeepL v3, delete) +- Translation with glossary +- Widget translation CRUD (upsert, batch upsert, query, delete) +- Authentication checks (401 for unauthorized requests) diff --git a/packages/ui/docs/layoutcontainer-ex.md b/packages/ui/docs/layoutcontainer-ex.md new file mode 100644 index 00000000..0d611ed1 --- /dev/null +++ b/packages/ui/docs/layoutcontainer-ex.md @@ -0,0 +1,642 @@ +# LayoutContainer-Ex: Rows with Adjustable Columns + +## Investigation Summary + +The goal is to support a **row-based layout** where each row can have independently adjustable column widths (e.g. 30/70, 50/50, 33/33/34). This document analyses the current architecture, identifies every surface that would need changes, and assesses a "build new + keep old" strategy. + +--- + +## Current Architecture + +### Data Model (`LayoutManager.ts`) + +```typescript +interface LayoutContainer { + id: string; + type: 'container'; + columns: number; // ← single integer, drives CSS grid-cols-N + gap: number; // ← pixel gap between cells + widgets: WidgetInstance[]; + children: LayoutContainer[]; + order?: number; + settings?: { + collapsible?: boolean; + collapsed?: boolean; + title?: string; + showTitle?: boolean; + customClassName?: string; + enabled?: boolean; + }; +} +``` + +**Key observations:** + +| Aspect | Current behaviour | +|--------|-------------------| +| **Column model** | `columns: number` — equal-width columns only (1–12). Rendered via Tailwind `grid-cols-N` classes. | +| **Widget placement** | Widgets have an `order` index. The CSS grid auto-flows them left-to-right, top-to-bottom. There is no "widget belongs to column X" field. | +| **Column targeting** | `addWidgetToContainer()` accepts `targetColumn?: number` but only uses it to calculate an insertion _index_ — it is a positional hint derived from `index % columns`, not a persistent assignment. | +| **Row concept** | None. Rows are an implicit side-effect of CSS grid wrapping. | +| **Nesting** | Containers can nest up to 3 levels deep. Nested containers span `col-span-full`. | +| **Container children** | `children: LayoutContainer[]` — nested containers always appear _after_ all widgets. | + +### Rendering (`LayoutContainer.tsx`) + +The component builds grid classes via `getGridClasses(columns)` → `grid-cols-1 md:grid-cols-N`. All widgets and children are rendered inside one `
`. There is no per-row logic. + +### Command System (`commands.ts`, `LayoutContext.tsx`) + +15+ command classes (Add/Remove/Move Widget, Add/Remove/Move Container, Update Columns, Update Settings, etc.) all operate on the flat `widgets[]` array and the `containers[]` / `children[]` tree. All are undo/redo-capable via `HistoryManager`. + +### Settings UI (`ContainerSettingsManager.tsx`) + +Only supports: title, showTitle, collapsible, collapsed. No column-width controls. + +--- + +## What "Rows with Adjustable Columns" Means + +A new `RowLayoutContainer` (or equivalent) would treat layout as: + +``` +Container + └─ Row 0: [Col 40%] [Col 60%] ← 2 columns, custom widths + └─ Row 1: [Col 33%] [Col 33%] [Col 34%] ← 3 columns + └─ Row 2: [Col 100%] ← full width +``` + +Each row is an independent unit with its own column count and width distribution. Widgets are placed in a specific **row + column** cell, not just by order. + +### Proposed Data Model + +```typescript +interface RowDef { + id: string; + columnWidths: number[]; // e.g. [40, 60] or [1, 2, 1] (fr units) + gap?: number; // row-level gap override +} + +interface RowLayoutContainer { + id: string; + type: 'row-layout'; // discriminator vs 'container' + rows: RowDef[]; + widgets: WidgetInstance[]; // each widget needs row + column assignment + gap: number; // vertical gap between rows + order?: number; + settings?: { /* same as current + potential new fields */ }; +} +``` + +Each `WidgetInstance` would need extended placement: + +```typescript +interface WidgetInstance { + id: string; + widgetId: string; + props?: Record; + order?: number; + // NEW — only for row-layout containers: + rowId?: string; + column?: number; +} +``` + +--- + +## UI Manipulation: Rows & Columns + +### Row Bar (edit mode only) + +Each row gets a thin header bar (like the existing container header), visible only in edit mode: + +``` +┌─ Row 1 ──────────────────────── [+ Col] [Split] [↑] [↓] [×] ─┐ +│ [ Widget A ] │ [ Widget B ] │ [ Widget C ] │ +└───────────────────────────────────────────────────────────────-┘ +┌─ Row 2 ──────────────────────── [+ Col] [Split] [↑] [↓] [×] ─┐ +│ [ Widget D ] │ [ Widget E ] │ +└───────────────────────────────────────────────────────────────-┘ + [ + Add Row ] +``` + +**Row-level actions:** + +| Button | Action | +|--------|--------| +| **+ Col** | Appends a new equal-width column to this row. E.g. `[1fr, 1fr]` → `[1fr, 1fr, 1fr]` | +| **Split** | Splits the last column into two. E.g. `[2fr, 1fr]` → `[2fr, 0.5fr, 0.5fr]` | +| **↑ / ↓** | Move row up / down within the container | +| **×** | Remove row (widgets in it get orphaned → prompt: delete widgets or move to adjacent row?) | +| **+ Add Row** | Appears below the last row. Creates a new single-column row at the bottom. | + +### Adding / Removing Columns per Row + +Columns exist per-row, not per-container. Each row stores its own `columnWidths: number[]`. + +**Adding a column:** +- Click **+ Col** on the row bar → appends `1fr` to the row's `columnWidths`. +- Or right-click a column divider → "Insert column left / right". +- New column starts empty. + +**Removing a column:** +- Right-click a column or its divider → "Remove column". +- If the column contains widgets: prompt to move them to the adjacent column (left preference) or delete them. +- If it's the last column: row becomes single-column, not removed. +- Removing the last column of the last row does NOT auto-remove the row (explicit delete required). + +**Merging columns:** +- Select two adjacent columns (shift-click column headers?) → "Merge" action. +- Merges `columnWidths` entries by summing them: `[1fr, 2fr, 1fr]` → merge cols 1+2 → `[3fr, 1fr]`. +- Widgets from both columns are stacked vertically in the merged column in their original order. + +### Column Width Adjustment + +**Drag handles** between columns: + +``` +│ Col 1 (2fr) ┃↔┃ Col 2 (1fr) │ Col 3 (1fr) │ + ↑ + drag handle +``` + +- A vertical drag handle between each pair of adjacent columns. +- Dragging redistributes width between the two neighbors only (the rest stay fixed). +- Minimum column width: `0.5fr` or `50px` — prevents collapse to zero. +- The handle shows a subtle resize cursor on hover. +- During drag: live preview with a ghost overlay showing the new widths. +- On release: `columnWidths` array is updated, triggers `ResizeRowColumnsCommand` (undoable). + +**Presets** (accessible from row bar dropdown or right-click): + +| Preset | `columnWidths` | +|--------|---------------| +| Equal halves | `[1, 1]` | +| Equal thirds | `[1, 1, 1]` | +| Sidebar left | `[1, 3]` | +| Sidebar right | `[3, 1]` | +| Golden ratio | `[1.618, 1]` | +| Wide center | `[1, 2, 1]` | +| Custom... | Opens a text input for arbitrary `fr` values | + +### Row Reordering + +- **↑ / ↓ buttons** on the row bar for keyboard-friendly reordering. +- Potential: **drag-to-reorder** via a grip handle on the row bar's left edge (lower priority, buttons first). + +--- + +## Sizing Modes + +Each column cell can operate in one of two sizing modes. This controls the **height behaviour** of the cell and therefore the row. + +### Constrained Mode (default) + +``` +columnWidths: [1, 2, 1] +sizing: 'constrained' // or simply: no override +``` + +- The row's height is dictated by the **tallest cell** in the row (CSS grid default `align-items: stretch`). +- Each cell stretches to match the row height. +- Widgets inside a cell fill available space or scroll if they overflow. +- **Use case:** Traditional grid layouts, dashboards, side-by-side cards of equal height. + +CSS implementation: +```css +.row { + display: grid; + grid-template-columns: 1fr 2fr 1fr; /* from columnWidths */ + align-items: stretch; /* all cells same height */ +} +``` + +### Unconstrained Mode (content-driven) + +``` +sizing: 'unconstrained' +``` + +- Each cell's height is determined by its own content (**intrinsic sizing**). +- Cells in the same row can have different heights — the row shrinks to fit. +- The row height equals the tallest cell, but shorter cells do **not** stretch (they align to top). +- **Use case:** Content blocks where one column has a short heading and the other has a long article — you don't want the short column to have a huge empty gap. + +CSS implementation: +```css +.row { + display: grid; + grid-template-columns: 1fr 2fr 1fr; + align-items: start; /* cells don't stretch */ +} +``` + +### Per-Cell Override (stretch / start / center / end) + +For finer control, each cell could also have its own vertical alignment: + +| Value | Behaviour | +|-------|-----------| +| `stretch` | Cell fills row height (default in constrained mode) | +| `start` | Cell sticks to top, height = content | +| `center` | Cell vertically centered within row | +| `end` | Cell sticks to bottom | + +This maps directly to CSS `align-self` on the grid cell. + +### Width Sizing: `fr` vs Fixed + +Column widths can mix `fr` (fractional) and fixed (px / rem) units: + +```typescript +interface ColumnDef { + width: number; + unit: 'fr' | 'px' | 'rem' | '%'; +} +``` + +| Scenario | `columnWidths` | CSS | +|----------|---------------|-----| +| All flexible | `[1fr, 2fr, 1fr]` | `grid-template-columns: 1fr 2fr 1fr` | +| Fixed sidebar | `[250px, 1fr]` | `grid-template-columns: 250px 1fr` | +| Mixed | `[200px, 1fr, 300px]` | `grid-template-columns: 200px 1fr 300px` | + +Fixed columns **don't resize** on drag — the drag handle only redistributes the `fr`-based columns. Fixed columns can be resized via the settings panel or by double-clicking the drag handle to convert to `fr`. + +### Mobile Responsive Collapse + +All sizing modes collapse to `grid-template-columns: 1fr` on mobile (`< md` breakpoint), matching the existing container behaviour. Per-row mobile overrides (e.g. "keep 2 columns on tablet") are a possible future extension but not required for v1. + +### Data Model Update for Sizing + +```typescript +interface RowDef { + id: string; + columns: ColumnDef[]; // replaces simple columnWidths: number[] + gap?: number; + sizing?: 'constrained' | 'unconstrained'; // row-level default + cellAlignments?: ('stretch' | 'start' | 'center' | 'end')[]; // per-cell override +} + +interface ColumnDef { + width: number; + unit: 'fr' | 'px' | 'rem' | '%'; + minWidth?: number; // collapse protection, in px +} +``` + +### Settings UI for Sizing + +In the row settings (accessible from the row bar ⚙ or the ContainerSettingsManager): + +- **Row sizing mode** toggle: Constrained / Unconstrained +- **Per-cell alignment** dropdown (only visible in unconstrained mode) +- **Column width type** per column: `fr` / `px` / `%` with numeric input +- **Column min-width** (optional, safety net) + +--- + +## CSS Implementation: Grid vs Flexbox + +### Why the Current Container Uses Tailwind Grid + +The existing `LayoutContainer` builds classes like `grid grid-cols-1 md:grid-cols-3 gap-4`. This works because: + +- **Equal-width columns only** → Tailwind's `grid-cols-N` maps directly to `grid-template-columns: repeat(N, minmax(0, 1fr))`. +- **Auto-flow** → Widgets land in the next available cell. No explicit row/column assignment needed. +- **Responsive** → `md:grid-cols-3` collapses to `grid-cols-1` on mobile for free. + +But Tailwind grid classes **cannot** express arbitrary column widths like `1fr 2fr 1fr` or `250px 1fr`. There's no `grid-cols-[1fr_2fr_1fr]` utility without JIT arbitrary values, and even then it gets messy with responsive variants. + +### Row-Layout: Inline `style` for Column Widths + +For arbitrary column widths, we **must** use inline `style` on the row div. Tailwind remains useful for everything else (gap, alignment, responsive collapse, padding). + +```tsx +// Row renderer — one per RowDef +const RowRenderer: React.FC<{ row: RowDef; children: React.ReactNode }> = ({ row, children }) => { + // Build grid-template-columns from ColumnDef[] + const gridTemplateColumns = row.columns + .map(col => `${col.width}${col.unit}`) + .join(' '); + // e.g. "1fr 2fr 1fr" or "250px 1fr 300px" + + return ( +
+ {children} +
+ ); +}; +``` + +**Key pattern:** Tailwind for behaviour/layout mode + inline `style` for dynamic values that can't be known at build time. + +### Where Tailwind Still Works + +| Concern | Tailwind class | Notes | +|---------|---------------|-------| +| Grid mode | `grid` | Always grid, never flex for the row itself | +| Gap | `gap-2`, `gap-4`, etc | For fixed gap values; use inline `style.gap` for custom px | +| Alignment | `items-start`, `items-stretch`, `items-center`, `items-end` | Row-level vertical alignment | +| Per-cell align | `self-start`, `self-center`, `self-end`, `self-stretch` | On the cell wrapper div | +| Responsive collapse | `max-md:grid-cols-1` or `grid-cols-1 md:grid-cols-none` | Override inline columns on mobile | +| Min heights | `min-h-[80px]` | Empty cell placeholders in edit mode | +| Overflow | `min-w-0 overflow-hidden` | Prevent blowoff from wide content | +| Edit mode borders | `border-2 border-blue-500` | Selection highlighting | + +### Where Tailwind Does NOT Work (Must Use Inline Style) + +| Concern | Why | Solution | +|---------|-----|----------| +| `grid-template-columns` | Arbitrary `fr`/`px`/`%` combos unknown at build time | `style={{ gridTemplateColumns }}` | +| Row gap (custom px) | Dynamic per-row value | `style={{ gap: '${row.gap}px' }}` | +| Drag-resize preview | Changes every frame during drag | `style={{ gridTemplateColumns }}` updated via `useState` or ref | + +### Why Flexbox is Worse for the Row Grid + +Flexbox _could_ theoretically work for column layout, but it has hard disadvantages for this use case: + +| Aspect | CSS Grid | Flexbox | +|--------|----------|---------| +| **Arbitrary column widths** | `grid-template-columns: 1fr 2fr 1fr` — one property | Must set `flex-basis` + `flex-grow` on each child. `flex: 2 1 0%` for 2fr. Fragile. | +| **Equal-height cells** | `align-items: stretch` works by default | Works too (`align-items: stretch`), but only for single-row flex. Multi-line flex (`flex-wrap`) breaks this. | +| **Cell alignment overrides** | `align-self: start` on cell | Works the same way ✓ | +| **Gap** | `gap: 16px` — native grid gap | `gap` works in modern browsers, but older flex shims needed `margin` hacks | +| **Mixed units** | `250px 1fr 300px` — trivial | Need calc: `flex: 0 0 250px` for fixed, `flex: 1 1 0%` for fr. No native mixing. | +| **Content overflow** | `minmax(0, 1fr)` prevents blowout | `min-width: 0` on each child, plus `flex-shrink` tuning | +| **Responsive collapse** | Override `grid-template-columns: 1fr` | Must flip `flex-direction: column` AND reset all flex-basis values | +| **Drag-resize** | Update one `style.gridTemplateColumns` string | Must update N child styles simultaneously | + +**Verdict:** CSS Grid is clearly superior for the row-based approach. The only thing flex is better at is natural content-flow wrapping, which we don't want — we want explicit column control. + +### Flexbox: Where It IS Useful + +Flex is still the right tool **inside cells**, not for the row grid itself: + +```tsx +// Cell wrapper — uses flex for vertical widget stacking within a cell +const CellRenderer: React.FC<{ alignment?: string; children: React.ReactNode }> = ({ alignment, children }) => ( +
+ {children} +
+); +``` + +Each cell is a **flex column** that stacks multiple widgets vertically. The grid is only for the row itself. + +### Full Row-Layout Renderer Skeleton + +```tsx +const RowLayoutRenderer: React.FC<{ container: RowLayoutContainer; isEditMode: boolean }> = ({ + container, + isEditMode, +}) => { + return ( +
+ {container.rows.map((row, rowIndex) => { + // Build grid-template-columns + const gtc = row.columns.map(c => `${c.width}${c.unit}`).join(' '); + + return ( +
+ {/* Row header bar (edit mode only) */} + {isEditMode && ( + + )} + + {/* Row grid */} +
+ {row.columns.map((col, colIndex) => { + // Find widgets assigned to this row + column + const cellWidgets = container.widgets + .filter(w => w.rowId === row.id && w.column === colIndex) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const cellAlign = row.cellAlignments?.[colIndex]; + + return ( +
+ {cellWidgets.map(widget => ( + + ))} +
+ ); + })} + + {/* Drag handles between columns (edit mode only) */} + {isEditMode && row.columns.length > 1 && ( + + )} +
+
+ ); + })} + + {/* Add Row button */} + {isEditMode && ( + + )} +
+ ); +}; +``` + +### Drag Handle Implementation Notes + +The column drag handles sit **on top of** the grid as absolutely positioned elements, not as grid children (they'd break the column count): + +```tsx +const ColumnDragHandles: React.FC<{ row: RowDef; onResize: (rowId: string, colIndex: number, delta: number) => void }> = ({ + row, onResize +}) => { + // Positioned absolutely over the row. One handle between each pair of columns. + // Each handle is a thin vertical bar at the column boundary. + + return ( +
+ {row.columns.slice(0, -1).map((_, i) => { + // Calculate left offset: sum of column widths up to (i+1) + // For fr units, convert to percentages based on total fr + const totalFr = row.columns.reduce((sum, c) => sum + (c.unit === 'fr' ? c.width : 0), 0); + const leftFr = row.columns.slice(0, i + 1).reduce((sum, c) => sum + (c.unit === 'fr' ? c.width : 0), 0); + const leftPercent = (leftFr / totalFr) * 100; + + return ( +
handleDragStart(e, row.id, i)} + /> + ); + })} +
+ ); +}; +``` + +> [!NOTE] +> The `left` percentage calculation above is simplified for all-`fr` rows. Mixed `fr`/`px` rows need the browser's computed column positions via `getComputedStyle()` or `getBoundingClientRect()` on the cell elements. + +### Responsive Collapse Pattern + +```css +/* Row grid: arbitrary columns on desktop, single column on mobile */ +.row-grid { + display: grid; + /* grid-template-columns set via inline style */ +} + +@media (max-width: 768px) { + .row-grid { + grid-template-columns: 1fr !important; /* Override inline style */ + } +} +``` + +In Tailwind, this is `max-md:!grid-cols-1` (with `!` for `!important` to override inline styles). This is the **one place** where `!important` is justified — inline styles normally win specificity, but responsive collapse must override them. + +--- + +## Impact Analysis + +### 🟢 No-impact (if we keep old `LayoutContainer` as-is) + +| Component | Reason | +|-----------|--------| +| All existing saved page data | Old pages use `type: 'container'`, unaffected | +| `LayoutContainerWidget.tsx` | Embeds `GenericCanvas`, which dispatches per container type | +| Existing undo/redo commands for old containers | Untouched | + +### 🟡 Needs extension (dual support) + +| Component | What changes | +|-----------|-------------| +| **`LayoutManager.ts`** | New `RowLayoutContainer` interface, new `type` discriminator. `UnifiedLayoutManager` needs: `addRowToContainer()`, `removeRow()`, `resizeRowColumns()`, `findContainerOrRowLayout()`. `addWidgetToContainer` needs a row-layout branch. | +| **`GenericCanvas.tsx`** | Must render `RowLayoutContainer` alongside `LayoutContainer`. Fork rendering by `container.type`. | +| **`PageLayout`** | `containers: (LayoutContainer \| RowLayoutContainer)[]` — union type. | +| **`commands.ts`** | New commands: `AddRowCommand`, `RemoveRowCommand`, `ResizeRowColumnsCommand`, `MoveWidgetToRowCellCommand`. Existing `MoveWidgetCommand` needs row-awareness. | +| **`LayoutContext.tsx`** | New context methods: `addRow`, `removeRow`, `resizeRowColumns`, `moveWidgetToCell`. Wire to new commands. | +| **Widget Palette / Add Widget flow** | Must know if targeting a row-layout and prompt for row + column. | +| **`ContainerSettingsManager.tsx`** | Row-layout variant needs row editor (add/remove rows, drag column dividers). | +| **`ContainerPropertyPanel.tsx`** | Show row structure for selected row-layout container. | + +### 🔴 High-complexity areas + +| Area | Complexity | Notes | +|------|-----------|-------| +| **Column width drag UI** | High | Need a drag-handle between columns per row, with live preview. Could use CSS `resize` or custom drag logic. | +| **Widget move across rows** | Medium | `MoveWidgetCommand` currently moves by `order ± 1`. In row-layout, moving down/up means crossing row boundaries, moving left/right means changing column. Completely different semantics. | +| **Export / Import** | Low-Medium | `exportPageLayout` / `importPageLayout` already serialise the full tree. A new `type` field will survive JSON round-trip, but import from old format needs migration. | +| **AI Layout Wizard** | Low | `BatchAddContainersCommand` generates `LayoutContainer[]`. Would need an equivalent for row-layouts or teach the AI about the new format. | +| **Clipboard (copy/paste)** | Medium | `SelectionContext` and clipboard logic need to handle the new type. | +| **SSR / Email / PDF rendering** | Medium | These use `iterateWidgets()` from `@polymech/shared`. That utility would need row-layout traversal support. | + +--- + +## Strategy: Build New, Keep Old + +**Recommended.** The cleanest path: + +1. **Add discriminated union** on `type: 'container' | 'row-layout'` in the data model. +2. **Build `RowLayoutContainer.tsx`** as a new renderer alongside the existing one. +3. **Fork in `GenericCanvas.tsx`** based on container type. +4. **New commands** — keep all existing commands untouched. +5. **Add "Row Layout" to Widget Palette** as a new container type option. +6. **ContainerSettingsManager** gets a second mode for row-layout configuration. + +### What we DON'T have to change (back-compat) + +- Existing `LayoutContainer` type, rendering, and commands remain untouched. +- Existing page data (`type: 'container'`) loads and renders as before. +- Old `getGridClasses()` stays as-is. + +### What we MUST build (new) + +| # | Deliverable | Est. LoC | +|---|-------------|----------| +| 1 | `RowDef` / `RowLayoutContainer` types in `LayoutManager.ts` | ~30 | +| 2 | `RowLayoutContainer.tsx` — rendering component | ~300 | +| 3 | `GenericCanvas.tsx` — type fork for rendering | ~20 | +| 4 | Row-specific commands (Add/Remove/Resize Row, Move Widget to Cell) | ~300 | +| 5 | `LayoutContext.tsx` — new context methods + wiring | ~100 | +| 6 | Row editor UI (column width dragger per row) | ~250 | +| 7 | `ContainerSettingsManager` row-layout mode or separate component | ~150 | +| 8 | `iterateWidgets` update in `@polymech/shared` | ~20 | +| 9 | Widget Palette "Add Row Layout" option | ~20 | +| **Total** | | **~1200** | + +--- + +## Alternative: Extend Current Container + +Instead of a new type, we could add `columnWidths?: number[]` to existing `LayoutContainer` and make each row an implicit `LayoutContainer` child. Pros: less new code. Cons: + +- Pollutes existing model with optional fields that only matter for row mode. +- Every existing command and renderer gains branches. +- Risk of subtle bugs in existing pages if `columnWidths` accidentally gets set. +- Harder to reason about; "container" means two very different things. + +**Verdict:** Not recommended. The discriminated union approach is safer and cleaner. + +--- + +## Open Questions + +1. **Column width units** — pixels, percentages, or CSS `fr` units? `fr` is most flexible for responsive design (`grid-template-columns: 1fr 2fr 1fr`). +2. **Max rows per container?** — Unbounded or capped? +3. **Should rows be re-orderable?** — Drag-to-reorder rows, or just add/remove? +4. **Mobile collapse** — Do multi-column rows stack to 1-column on mobile (like current), or allow per-row mobile config? +5. **Can a row-layout contain nested containers?** — Probably yes (in a cell), but adds complexity. +6. **Migration path** — Should there be a "Convert container → row-layout" action? + +--- + +## Conclusion + +Building a new `RowLayoutContainer` alongside the existing `LayoutContainer` is the safest and cleanest approach. It avoids touching ~2000 lines of working container code + commands, while adding ~1200 lines of new purpose-built code. The main complexity is in the **column-width drag UI** and **widget movement semantics** across row/column cells. Everything else is mechanical wiring. diff --git a/packages/ui/docs/testing-client.md b/packages/ui/docs/testing-client.md index 16f836dd..7bbed2ea 100644 --- a/packages/ui/docs/testing-client.md +++ b/packages/ui/docs/testing-client.md @@ -61,12 +61,12 @@ cd ../polymech-mono/packages/acl && npm run build ```env TEST_EMAIL="cgoflyn@gmail.com" ( admin user) -TEST_PASSWORD="213,,asd" +TEST_PASSWORD="...." TEST_USER_ID="cgo" TEST_EMAIL_REGULAR="sales@plastic-hub.com" (regular user) -TEST_PASSWORD_REGULAR="213,,asd" +TEST_PASSWORD_REGULAR="..." VITE_SUPABASE_URL="https://…supabase.co" diff --git a/packages/ui/shared/package-lock.json b/packages/ui/shared/package-lock.json index 409df090..e50635c2 100644 --- a/packages/ui/shared/package-lock.json +++ b/packages/ui/shared/package-lock.json @@ -1,18 +1,18 @@ { - "name": "@polymech/pm-pics-shared", + "name": "@polymech/shared", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@polymech/pm-pics-shared", + "name": "@polymech/shared", "version": "1.0.0", "dependencies": { - "@hono/zod-openapi": "^1.1.5" + "@hono/zod-openapi": "^1.1.5", + "zod": "^4.3.6" }, "devDependencies": { - "typescript": "^5.0.0", - "zod": "^4.1.12" + "typescript": "^5.0.0" } }, "../../../polymech-mono/packages/commons": { @@ -135,9 +135,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "peer": true, "funding": { diff --git a/packages/ui/shared/package.json b/packages/ui/shared/package.json index 8a499652..f0d1f136 100644 --- a/packages/ui/shared/package.json +++ b/packages/ui/shared/package.json @@ -16,6 +16,6 @@ }, "dependencies": { "@hono/zod-openapi": "^1.1.5", - "zod": "^3.24.1" + "zod": "^4.3.6" } } \ No newline at end of file diff --git a/packages/ui/shared/src/config/config.d.ts b/packages/ui/shared/src/config/config.d.ts new file mode 100644 index 00000000..f17460a5 --- /dev/null +++ b/packages/ui/shared/src/config/config.d.ts @@ -0,0 +1,220 @@ +export interface AppConfig { + site: Site; + i18n: I18N; + cad: Cad; + core: Core; + footer_left: FooterLeft[]; + footer_right: any[]; + settings: Settings; + params: Params; + navigation: Navigation; + navigation_button: NavigationButton; + ecommerce: Ecommerce; + metadata: Metadata; + shopify: Shopify; + pages: Pages; + dev: Dev; + features: { [key: string]: boolean }; + retail: Retail; + osrl: Osrl; + products: Products; + defaults: Defaults; + assets: Assets; + optimization: Optimization; +} + +export interface Assets { + local: boolean; + glob: string; + cad_url: string; + url: string; + item_url_r: string; + item_url: string; +} + +export interface Cad { + cache: boolean; + export_configurations: boolean; + export_sub_components: boolean; + renderer: string; + renderer_view: string; + renderer_quality: number; + extensions: string[]; + model_ext: string; + default_configuration: string; + main_match: string; + cam_main_match: string; +} + +export interface Core { + logging_namespace: string; + translate_content: boolean; + languages: string[]; + languages_prod: string[]; + rtl_languages: string[]; + osr_root: string; +} + +export interface Defaults { + image_url: string; + license: string; + contact: string; +} + +export interface Dev { + file_server: string; +} + +export interface Ecommerce { + brand: string; + currencySymbol: string; + currencyCode: string; +} + +export interface FooterLeft { + href: string; + text: string; +} + +export interface I18N { + store: string; + cache: boolean; + source_language: string; + asset_path: string; +} + +export interface Metadata { + country: string; + city: string; + author: string; + author_bio: string; + author_url: string; + image: string; + description: string; + keywords: string; +} + +export interface Navigation { + top: FooterLeft[]; +} + +export interface NavigationButton { + enable: boolean; + label: string; + link: string; +} + +export interface Optimization { + image_settings: ImageSettings; + presets: Presets; +} + +export interface ImageSettings { + gallery: Gallery; + lightbox: Gallery; +} + +export interface Gallery { + show_title: boolean; + show_description: boolean; + sizes_thumb: string; + sizes_large: string; + sizes_regular: string; +} + +export interface Presets { + slow: Fast; + medium: Fast; + fast: Fast; +} + +export interface Fast { + sizes_medium: string; + sizes_thumbs: string; + sizes_large: string; +} + +export interface Osrl { + env: string; + env_dev: string; + module_name: string; + lang_flavor: string; + product_profile: string; +} + +export interface Pages { + home: Home; +} + +export interface Home { + hero: string; + _blog: Blog; +} + +export interface Blog { + store: string; +} + +export interface Params { + contact_form_action: string; + copyright: string; +} + +export interface Products { + root: string; + howto_migration: string; + glob: string; + enabled: string; +} + +export interface Retail { + products: string; + library_branch: string; + projects_branch: string; + compile_cache: boolean; + media_cache: boolean; + log_level_i18n_product_assets: string; + convert_product_media: boolean; + translate_product_assets: boolean; + populate_product_defaults: boolean; +} + +export interface Settings { + search: boolean; + account: boolean; + sticky_header: boolean; + theme_switcher: boolean; + default_theme: string; +} + +export interface Shopify { + currencySymbol: string; + currencyCode: string; + collections: Collections; +} + +export interface Collections { + hero_slider: string; + featured_products: string; +} + +export interface Site { + title: string; + base_url: string; + description: string; + base_path: string; + trailing_slash: boolean; + favicon: string; + logo: string; + logo_darkmode: string; + logo_width: string; + logo_height: string; + logo_text: string; + image: Image; +} + +export interface Image { + default: string; + error: string; + alt: string; +} diff --git a/packages/ui/shared/src/config/config.schema.ts b/packages/ui/shared/src/config/config.schema.ts new file mode 100644 index 00000000..41e009e7 --- /dev/null +++ b/packages/ui/shared/src/config/config.schema.ts @@ -0,0 +1,225 @@ +// Generated by ts-to-zod +import { z } from "zod"; + +export const i18nSchema = z.object({ + store: z.string(), + cache: z.boolean(), + source_language: z.string(), + asset_path: z.string(), +}); + +export const cadSchema = z.object({ + cache: z.boolean(), + export_configurations: z.boolean(), + export_sub_components: z.boolean(), + renderer: z.string(), + renderer_view: z.string(), + renderer_quality: z.number(), + extensions: z.array(z.string()), + model_ext: z.string(), + default_configuration: z.string(), + main_match: z.string(), + cam_main_match: z.string(), +}); + +export const coreSchema = z.object({ + logging_namespace: z.string(), + translate_content: z.boolean(), + languages: z.array(z.string()), + languages_prod: z.array(z.string()), + rtl_languages: z.array(z.string()), + osr_root: z.string(), +}); + +export const footerLeftSchema = z.object({ + href: z.string(), + text: z.string(), +}); + +export const settingsSchema = z.object({ + search: z.boolean(), + account: z.boolean(), + sticky_header: z.boolean(), + theme_switcher: z.boolean(), + default_theme: z.string(), +}); + +export const paramsSchema = z.object({ + contact_form_action: z.string(), + copyright: z.string(), +}); + +export const navigationSchema = z.object({ + top: z.array(footerLeftSchema), +}); + +export const navigationButtonSchema = z.object({ + enable: z.boolean(), + label: z.string(), + link: z.string(), +}); + +export const ecommerceSchema = z.object({ + brand: z.string(), + currencySymbol: z.string(), + currencyCode: z.string(), +}); + +export const metadataSchema = z.object({ + country: z.string(), + city: z.string(), + author: z.string(), + author_bio: z.string(), + author_url: z.string(), + image: z.string(), + description: z.string(), + keywords: z.string(), +}); + +export const devSchema = z.object({ + file_server: z.string(), +}); + +export const retailSchema = z.object({ + products: z.string(), + library_branch: z.string(), + projects_branch: z.string(), + compile_cache: z.boolean(), + media_cache: z.boolean(), + log_level_i18n_product_assets: z.string(), + convert_product_media: z.boolean(), + translate_product_assets: z.boolean(), + populate_product_defaults: z.boolean(), +}); + +export const osrlSchema = z.object({ + env: z.string(), + env_dev: z.string(), + module_name: z.string(), + lang_flavor: z.string(), + product_profile: z.string(), +}); + +export const productsSchema = z.object({ + root: z.string(), + howto_migration: z.string(), + glob: z.string(), + enabled: z.string(), +}); + +export const defaultsSchema = z.object({ + image_url: z.string(), + license: z.string(), + contact: z.string(), +}); + +export const assetsSchema = z.object({ + local: z.boolean(), + glob: z.string(), + cad_url: z.string(), + url: z.string(), + item_url_r: z.string(), + item_url: z.string(), +}); + +export const gallerySchema = z.object({ + show_title: z.boolean(), + show_description: z.boolean(), + sizes_thumb: z.string(), + sizes_large: z.string(), + sizes_regular: z.string(), +}); + +export const fastSchema = z.object({ + sizes_medium: z.string(), + sizes_thumbs: z.string(), + sizes_large: z.string(), +}); + +export const blogSchema = z.object({ + store: z.string(), +}); + +export const collectionsSchema = z.object({ + hero_slider: z.string(), + featured_products: z.string(), +}); + +export const imageSchema = z.object({ + default: z.string(), + error: z.string(), + alt: z.string(), +}); + +export const siteSchema = z.object({ + title: z.string(), + base_url: z.string(), + description: z.string(), + base_path: z.string(), + trailing_slash: z.boolean(), + favicon: z.string(), + logo: z.string(), + logo_darkmode: z.string(), + logo_width: z.string(), + logo_height: z.string(), + logo_text: z.string(), + image: imageSchema, +}); + +export const shopifySchema = z.object({ + currencySymbol: z.string(), + currencyCode: z.string(), + collections: collectionsSchema, +}); + +export const imageSettingsSchema = z.object({ + gallery: gallerySchema, + lightbox: gallerySchema, +}); + +export const presetsSchema = z.object({ + slow: fastSchema, + medium: fastSchema, + fast: fastSchema, +}); + +export const homeSchema = z.object({ + hero: z.string(), + _blog: blogSchema, +}); + +export const pagesSchema = z.object({ + home: homeSchema, +}); + +export const optimizationSchema = z.object({ + image_settings: imageSettingsSchema, + presets: presetsSchema, +}); + +export const appConfigSchema = z.object({ + site: siteSchema, + i18n: i18nSchema, + cad: cadSchema, + core: coreSchema, + footer_left: z.array(footerLeftSchema), + footer_right: z.array(z.any()), + settings: settingsSchema, + params: paramsSchema, + navigation: navigationSchema, + navigation_button: navigationButtonSchema, + ecommerce: ecommerceSchema, + metadata: metadataSchema, + shopify: shopifySchema, + pages: pagesSchema, + dev: devSchema, + features: z.record(z.string(), z.boolean()), + retail: retailSchema, + osrl: osrlSchema, + products: productsSchema, + defaults: defaultsSchema, + assets: assetsSchema, + optimization: optimizationSchema, +}); + +export type AppConfig = z.infer; diff --git a/packages/ui/shared/src/ui/schemas.ts b/packages/ui/shared/src/ui/schemas.ts index d3481769..adff9326 100644 --- a/packages/ui/shared/src/ui/schemas.ts +++ b/packages/ui/shared/src/ui/schemas.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v4'; // ========================================= // Utility Enums & Common Schemas @@ -58,10 +58,6 @@ export const PhotoCardWidgetSchema = z.object({ pictureId: 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), - showTitle: z.boolean().default(true), - showDescription: z.boolean().default(true), contentDisplay: ContentDisplaySchema.default('below'), imageFit: ImageFitSchema.default('cover'), variables: WidgetVariablesSchema, @@ -260,14 +256,14 @@ export type RootLayoutData = z.infer; ///////////////////////////////////////////////////////////////////////////////////// const PageBaseSchema = z.object({ - id: z.string().guid().optional(), + id: z.string().uuid().optional(), title: z.string().min(1).max(255), slug: z.string().min(1).max(255), - owner: z.string().guid(), + owner: z.string().uuid(), content: RootLayoutDataSchema.loose().optional(), // Enforce RootLayout structure for new/updated pages is_public: z.boolean().optional(), visible: z.boolean().optional(), - parent: z.string().guid().nullable().optional(), + parent: z.string().uuid().nullable().optional(), meta: z.any().optional(), // JSONB created_at: z.string().optional(), updated_at: z.string().optional() @@ -284,14 +280,14 @@ export const UpdatePageSchema = PageBaseSchema.partial().omit({ id: true, owner: // --- User Page Details Schemas --- export const UserProfileSchema = z.object({ - user_id: z.string().guid(), + user_id: z.string().uuid(), username: z.string(), display_name: z.string().nullable().optional(), avatar_url: z.string().nullable().optional(), }); export const SimplePageSchema = z.object({ - id: z.string().guid(), + id: z.string().uuid(), title: z.string(), slug: z.string(), visible: z.boolean().optional(), diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8901312e..1b97f869 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -4,8 +4,8 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@/lib/queryClient"; -import { BrowserRouter, Routes, Route, useLocation, useNavigate } from "react-router-dom"; -import { AuthProvider, useAuth } from "@/hooks/useAuth"; +import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; +import { AuthProvider } from "@/hooks/useAuth"; import { PostNavigationProvider } from "@/contexts/PostNavigationContext"; import { OrganizationProvider } from "@/contexts/OrganizationContext"; import { LogProvider } from "@/contexts/LogContext"; @@ -24,21 +24,22 @@ registerAllWidgets(); import Index from "./pages/Index"; import Auth from "./pages/Auth"; +const UpdatePassword = React.lazy(() => import("./pages/UpdatePassword")); import Profile from "./pages/Profile"; -import Post from "./pages/Post"; +const Post = React.lazy(() => import("./pages/Post")); import UserProfile from "./pages/UserProfile"; import UserCollections from "./pages/UserCollections"; -import Collections from "./pages/Collections"; +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"; -import NewPost from "./pages/NewPost"; +const NewPost = React.lazy(() => import("./pages/NewPost")); import TagPage from "./pages/TagPage"; import SearchResults from "./pages/SearchResults"; -import Wizard from "./pages/Wizard"; +const Wizard = React.lazy(() => import("./pages/Wizard")); import Organizations from "./pages/Organizations"; import LogsPage from "./components/logging/LogsPage"; @@ -85,23 +86,24 @@ const AppWrapper = () => { {/* Top-level routes (no organization context) */} } /> } /> + Loading...
}>} /> } /> - } /> - } /> + Loading...
}>} /> + Loading...}>} /> } /> } /> } /> Loading...}>} /> } /> - } /> + Loading...}>} /> } /> } /> } /> } /> } /> } /> - } /> - } /> + Loading...}>} /> + Loading...}>} /> Loading map...}> diff --git a/packages/ui/src/components/AITextGenerator.tsx b/packages/ui/src/components/AITextGenerator.tsx index dd198641..b24eecd9 100644 --- a/packages/ui/src/components/AITextGenerator.tsx +++ b/packages/ui/src/components/AITextGenerator.tsx @@ -145,7 +145,6 @@ export const AITextGenerator: React.FC = ({ onNavigateHistory, }) => { const handleKeyDown = (e: React.KeyboardEvent) => { - console.log(e.key); // Ctrl+Enter to generate if ((e.key === 'Enter' && e.ctrlKey) && prompt.trim() && !isGenerating) { e.preventDefault(); @@ -228,7 +227,7 @@ export const AITextGenerator: React.FC = ({ - {imageToolsEnabled ? 'Enabled' : 'Text Only'} + {imageToolsEnabled ? Enabled : Text Only} @@ -246,7 +245,7 @@ export const AITextGenerator: React.FC = ({ - {webSearchEnabled ? 'On' : 'Off'} + {webSearchEnabled ? On : Off} @@ -264,7 +263,7 @@ export const AITextGenerator: React.FC = ({ @@ -280,7 +279,7 @@ export const AITextGenerator: React.FC = ({ ) : ( - None + None )} @@ -297,7 +296,7 @@ export const AITextGenerator: React.FC = ({ ) : ( - Empty + Empty )} diff --git a/packages/ui/src/components/CategoryTreeView.tsx b/packages/ui/src/components/CategoryTreeView.tsx index 10951ea5..1b75f22a 100644 --- a/packages/ui/src/components/CategoryTreeView.tsx +++ b/packages/ui/src/components/CategoryTreeView.tsx @@ -3,8 +3,9 @@ import { fetchCategories, type Category } from "@/modules/categories/client-cate import { useNavigate, useParams } from "react-router-dom"; import { cn } from "@/lib/utils"; import { useState, useCallback, useMemo } from "react"; -import { FolderTree, ChevronRight, ChevronDown, Home, Loader2 } from "lucide-react"; +import { FolderTree, ChevronRight, ChevronDown, Loader2 } from "lucide-react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { useAppConfig } from '@/hooks/useSystemInfo'; interface CategoryTreeViewProps { /** Called after a category is selected (useful for closing mobile sheet) */ @@ -40,9 +41,12 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => } catch { return new Set(); } }); + const appConfig = useAppConfig(); + const srcLang = appConfig?.i18n?.source_language; + const { data: categories = [], isLoading } = useQuery({ queryKey: ['categories'], - queryFn: () => fetchCategories({ includeChildren: true }), + queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang }), staleTime: 1000 * 60 * 5, }); @@ -133,19 +137,6 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) => return ( diff --git a/packages/ui/src/components/CreationWizardPopup.tsx b/packages/ui/src/components/CreationWizardPopup.tsx index f9bce92b..4548a733 100644 --- a/packages/ui/src/components/CreationWizardPopup.tsx +++ b/packages/ui/src/components/CreationWizardPopup.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from '@/components/ui/button'; import { T, translate } from '@/i18n'; import { Image, FilePlus, Zap, Mic, Loader2, Upload, Video, Layers, BookPlus, Plus } from 'lucide-react'; -import { useImageWizard } from '@/hooks/useImageWizard'; + import { usePageGenerator } from '@/hooks/usePageGenerator'; import VoiceRecordingPopup from './VoiceRecordingPopup'; @@ -15,7 +15,6 @@ import PostPicker from './PostPicker'; import { useWizardContext } from '@/hooks/useWizardContext'; import { usePromptHistory } from '@/hooks/usePromptHistory'; import { useAuth } from '@/hooks/useAuth'; -import { useOrganization } from '@/contexts/OrganizationContext'; import { useMediaRefresh } from '@/contexts/MediaRefreshContext'; import { createPicture } from '@/modules/posts/client-pictures'; import { fetchPostById } from '@/modules/posts/client-posts'; diff --git a/packages/ui/src/components/EditImageModal.tsx b/packages/ui/src/components/EditImageModal.tsx index c2522978..6cf85e3c 100644 --- a/packages/ui/src/components/EditImageModal.tsx +++ b/packages/ui/src/components/EditImageModal.tsx @@ -8,8 +8,6 @@ import { Switch } from '@/components/ui/switch'; import { Checkbox } from '@/components/ui/checkbox'; import { Card, CardContent } from '@/components/ui/card'; import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; import { supabase } from '@/integrations/supabase/client'; import { updatePicture } from '@/modules/posts/client-pictures'; import { useAuth } from '@/hooks/useAuth'; @@ -29,13 +27,11 @@ interface Collection { is_public: boolean; } -const editSchema = z.object({ - title: z.string().max(100, 'Title must be less than 100 characters').optional(), - description: z.string().max(1000, 'Description must be less than 1000 characters').optional(), - visible: z.boolean(), -}); - -type EditFormData = z.infer; +interface EditFormData { + title?: string; + description?: string; + visible: boolean; +} interface EditImageModalProps { open: boolean; @@ -74,7 +70,6 @@ const EditImageModal = ({ const [loadingCollections, setLoadingCollections] = useState(false); const form = useForm({ - resolver: zodResolver(editSchema), defaultValues: { title: currentTitle, description: currentDescription || '', @@ -405,8 +400,8 @@ const EditImageModal = ({ onClick={handleMicrophone} disabled={isTranscribing || updating} className={`p-1.5 rounded-md transition-colors ${isRecording - ? 'bg-red-100 text-red-600 hover:bg-red-200' - : 'text-muted-foreground hover:text-foreground hover:bg-accent' + ? 'bg-red-100 text-red-600 hover:bg-red-200' + : 'text-muted-foreground hover:text-foreground hover:bg-accent' }`} title={translate(isRecording ? 'Stop recording' : 'Record audio')} > @@ -510,8 +505,8 @@ const EditImageModal = ({ handleToggleCollection(collection.id)} > diff --git a/packages/ui/src/components/Footer.tsx b/packages/ui/src/components/Footer.tsx new file mode 100644 index 00000000..cf6d8f28 --- /dev/null +++ b/packages/ui/src/components/Footer.tsx @@ -0,0 +1,86 @@ +import { useAppConfig } from '@/hooks/useSystemInfo'; + +const policyLinks = [ + { label: "Returns & Refunds", href: "/returns" }, + { label: "Shipping", href: "/shipping" }, + { label: "Privacy Policy", href: "/privacy" }, + { label: "Terms of Service", href: "/terms" }, +]; + +const Footer = () => { + const config = useAppConfig(); + + if (!config) return null; + + const { footer_left = [], footer_right = [], metadata } = config; + const allLinks = [...footer_left, ...footer_right]; + + return ( +
+
+
+ {/* Column 1: Navigation links */} + {allLinks.length > 0 && ( + + )} + + {/* Column 2: Policy links */} + + + {/* Column 3: Copyright / meta */} +
+ {metadata?.author && ( + + © {new Date().getFullYear()}{' '} + {metadata.author_url ? ( + + {metadata.author} + + ) : ( + metadata.author + )} + + )} + {metadata?.description && ( + {metadata.description} + )} +
+
+
+
+ ); +}; + +export default Footer; diff --git a/packages/ui/src/components/GalleryLarge.tsx b/packages/ui/src/components/GalleryLarge.tsx index 7cf63e94..ae548182 100644 --- a/packages/ui/src/components/GalleryLarge.tsx +++ b/packages/ui/src/components/GalleryLarge.tsx @@ -1,17 +1,14 @@ import MediaCard from "./MediaCard"; -import React, { useEffect, useState, useRef } from "react"; +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"; -import * as db from '../pages/Post/db'; import type { MediaItem } from "@/types"; -import { supabase } from "@/integrations/supabase/client"; - -// Duplicate types for now or we could reuse specific generic props -// To minimalize refactoring PhotoGrid, I'll copy the logic but use the Feed variant +import { fetchUserMediaLikes } from "@/modules/posts/client-pictures"; +import { T } from '@/i18n'; import type { FeedSortOption } from '@/hooks/useFeedData'; import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts"; @@ -86,36 +83,20 @@ const GalleryLarge = ({ } }, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]); - const fetchUserLikes = async () => { + const refreshUserLikes = useCallback(async () => { if (!user || mediaItems.length === 0) return; - try { - // Collect IDs to check (picture_id for feed, id for collection/direct pictures) - const targetIds = mediaItems - .map(item => item.picture_id || item.id) - .filter(Boolean) as string[]; - - if (targetIds.length === 0) return; - - // Fetch likes only for the displayed items - const { data: likesData, error } = await supabase - .from('likes') - .select('picture_id') - .eq('user_id', user.id) - .in('picture_id', targetIds); - - if (error) throw error; - - // Merge new likes with existing set - setUserLikes(prev => { - const newSet = new Set(prev); - likesData?.forEach(l => newSet.add(l.picture_id)); - return newSet; - }); + const { pictureLikes } = await fetchUserMediaLikes(user.id); + setUserLikes(pictureLikes); } catch (error) { console.error('Error fetching user likes:', error); } - }; + }, [user, mediaItems.length]); + + // Fetch likes on mount and whenever the media list changes + useEffect(() => { + refreshUserLikes(); + }, [refreshUserLikes]); const handleError = () => { window.location.reload(); @@ -125,7 +106,7 @@ const GalleryLarge = ({ return (
- Loading gallery... + Loading gallery...
); @@ -135,8 +116,8 @@ const GalleryLarge = ({ return (
-

No media yet!

-

Be the first to share content with the community.

+

No media yet!

+

Be the first to share content with the community.

) @@ -165,7 +146,7 @@ const GalleryLarge = ({ type={itemType} meta={item.meta} onClick={() => navigate(`/post/${item.id}`)} - onLike={fetchUserLikes} + onLike={refreshUserLikes} onDelete={handleError} created_at={item.created_at} job={item.job} diff --git a/packages/ui/src/components/Header.tsx b/packages/ui/src/components/Header.tsx deleted file mode 100644 index 2b02b935..00000000 --- a/packages/ui/src/components/Header.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Camera, Search, Heart, User, Upload, Bell, LogOut, ListFilter } from "lucide-react"; -import { useAuth } from "@/hooks/useAuth"; -import { Link } from "react-router-dom"; -import UploadModal from "./UploadModal"; -import { ThemeToggle } from "@/components/ThemeToggle"; -import { useLog } from "@/contexts/LogContext"; - -const Header = () => { - const { user, signOut } = useAuth(); - const [uploadModalOpen, setUploadModalOpen] = useState(false); - const { isLoggerVisible, setLoggerVisible } = useLog(); - - return ( - <> -
-
-
- {/* Logo */} - -
- -
-

- TauriPics -

- - - {/* Search Bar */} -
-
- - -
-
- - {/* Navigation */} -
- - - {user ? ( - <> - - - - - - - - - ) : ( - - - - )} -
-
-
-
- - {user && ( - window.location.reload()} - /> - )} - - ); -}; - -export default Header; \ No newline at end of file diff --git a/packages/ui/src/components/HeroSection.tsx b/packages/ui/src/components/HeroSection.tsx deleted file mode 100644 index d451f148..00000000 --- a/packages/ui/src/components/HeroSection.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Camera, Upload, Sparkles } from "lucide-react"; -import { useAuth } from "@/hooks/useAuth"; -import { Link } from "react-router-dom"; -import UploadModal from "./UploadModal"; -import heroImage from "@/assets/hero-image.jpg"; - -const HeroSection = () => { - const { user } = useAuth(); - const [uploadModalOpen, setUploadModalOpen] = useState(false); - - const handleStartSharing = () => { - if (user) { - setUploadModalOpen(true); - } - }; - - return ( - <> -
- {/* Background Image */} -
- Photography background -
-
- - {/* Floating Elements */} -
-
-
-
-
- - {/* Content */} -
-
-
- - Share your world through photos -
-
- -

- - Capture - {" "} - & Share -
- Your{" "} - - Stories - -

- -

- Join millions of photographers sharing their passion. Discover breathtaking moments, - connect with creators, and showcase your unique perspective to the world. -

- -
- {user ? ( - - ) : ( - - - - )} - -
- -
-
-
10M+
-
Photos Shared
-
-
-
500K+
-
Active Users
-
-
-
1M+
-
Daily Views
-
-
-
-
- - {user && ( - window.location.reload()} - /> - )} - - ); -}; - -export default HeroSection; \ No newline at end of file diff --git a/packages/ui/src/components/ImageGallery.tsx b/packages/ui/src/components/ImageGallery.tsx index 05dff7c6..765d2efb 100644 --- a/packages/ui/src/components/ImageGallery.tsx +++ b/packages/ui/src/components/ImageGallery.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { ImageFile } from '../types'; import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; -import { ArrowUp } from 'lucide-react'; import { downloadImage, generateFilename } from '@/utils/downloadUtils'; import { toast } from 'sonner'; import { translate } from '@/i18n'; @@ -25,10 +24,10 @@ interface ImageGalleryProps { setErrorMessage?: (message: string | null) => void; } -export default function ImageGallery({ - images, - onImageSelection, - onImageRemove, +export default function ImageGallery({ + images, + onImageSelection, + onImageRemove, onImageDelete, onImageSaveAs, showSelection = false, @@ -115,7 +114,7 @@ export default function ImageGallery({ setSkipDeleteConfirm(true); } onImageDelete?.(images[safeIndex].path); - + // Close lightbox if this was the last image or adjust index if (images.length <= 1) { setLightboxOpen(false); @@ -140,7 +139,7 @@ export default function ImageGallery({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (!lightboxOpen) return; - + if (event.key === 'Escape') { setLightboxOpen(false); } else if (event.key === 'ArrowRight' && currentIndex < images.length - 1) { @@ -162,7 +161,7 @@ export default function ImageGallery({ const preloadImage = (index: number) => { if (images.length === 0 || index < 0 || index >= images.length) return; - + setLightboxLoaded(false); const img = new Image(); img.src = images[index].src; @@ -230,152 +229,150 @@ export default function ImageGallery({ return (
- {/* Left column: Main Image Display */} -
-
-
- {currentImage.path} openLightbox(safeIndex)} - title="Double-click for fullscreen" - /> -
- {/* Compact overlays */} - {isGenerated && isSelected && ( -
- - - - ✓ -
- )} - - {isGenerated && !isSelected && ( -
- ✨ -
- )} -
-
-
- - {/* Image Info */} -
-

- {currentImage.path.split(/[/\\]/).pop()} • {safeIndex + 1}/{images.length} -

-
-
- - {/* Right column: Thumbnails */} -
-

Images ({images.length})

-
- {images.map((image, index) => { - const thumbIsGenerating = image.path.startsWith('generating_'); - const thumbIsGenerated = !!image.isGenerated; - const thumbIsSelected = image.selected || false; - - return ( - - )} +
+
+
- {/* Delete Button */} - {!thumbIsGenerating && onImageDelete && ( - - )} - - ); - })} + {/* Image Info */} +
+

+ {currentImage.path.split(/[/\\]/).pop()} • {safeIndex + 1}/{images.length} +

+
+
+ + {/* Right column: Thumbnails */} +
+

Images ({images.length})

+
+ {images.map((image, index) => { + const thumbIsGenerating = image.path.startsWith('generating_'); + const thumbIsGenerated = !!image.isGenerated; + const thumbIsSelected = image.selected || false; + + return ( + + )} + + {/* Delete Button */} + {!thumbIsGenerating && onImageDelete && ( + + )} + + ); + })} +
- {/* Lightbox Modal */} {lightboxOpen && ( -
{ panStartRef.current = { x: e.clientX, y: e.clientY }; @@ -384,7 +381,7 @@ export default function ImageGallery({ onMouseMove={(e) => { if (panStartRef.current) { const distance = Math.sqrt( - Math.pow(e.clientX - panStartRef.current.x, 2) + + Math.pow(e.clientX - panStartRef.current.x, 2) + Math.pow(e.clientY - panStartRef.current.y, 2) ); if (distance > 5) { // 5px threshold for pan detection @@ -432,7 +429,7 @@ export default function ImageGallery({
)} - + {/* Close Button */} - + {/* Navigation Buttons */} {safeIndex > 0 && ( )} - + {/* Info */} {lightboxLoaded && (
@@ -489,7 +486,7 @@ export default function ImageGallery({ {/* Delete Confirmation Dialog */} {showDeleteConfirm && ( -
{ if (e.key === 'Enter') { diff --git a/packages/ui/src/components/ImageWizard.tsx b/packages/ui/src/components/ImageWizard.tsx index 9961fecf..75ab7602 100644 --- a/packages/ui/src/components/ImageWizard.tsx +++ b/packages/ui/src/components/ImageWizard.tsx @@ -557,7 +557,7 @@ const ImageWizard: React.FC = ({ const handlePresetClear = () => { setSelectedPreset(null); - toast.info('Preset context cleared'); + toast.info(translate('Preset context cleared')); }; const handleVoiceToImage = (transcribedText: string) => @@ -715,7 +715,7 @@ const ImageWizard: React.FC = ({ // Validate const invalid = editingActions.find(a => !a.name.trim() || !a.prompt.trim()); if (invalid) { - toast.error('All actions must have a name and prompt'); + toast.error(translate('All actions must have a name and prompt')); return; } diff --git a/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx b/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx index af050dc1..ad47de26 100644 --- a/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx +++ b/packages/ui/src/components/ImageWizard/components/ModelSelector.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { T } from '@/i18n'; + import { AVAILABLE_MODELS, getModelString } from '@/lib/image-router'; interface ModelSelectorProps { diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index b09e94b7..87faab78 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -1,15 +1,15 @@ import React, { useState, useEffect } from "react"; import { useFeedData, FeedSortOption } from "@/hooks/useFeedData"; -import { useOrganization } from "@/contexts/OrganizationContext"; + import { useIsMobile } from "@/hooks/use-mobile"; import { useNavigate } from "react-router-dom"; import { formatDistanceToNow } from "date-fns"; import { MessageCircle, Heart, ExternalLink } from "lucide-react"; import UserAvatarBlock from "@/components/UserAvatarBlock"; import { Button } from "@/components/ui/button"; -import Post from "@/pages/Post"; -import UserPage from "@/modules/pages/UserPage"; +const Post = React.lazy(() => import("@/pages/Post")); +const UserPage = React.lazy(() => import("@/modules/pages/UserPage")); interface ListLayoutProps { sortBy?: FeedSortOption; @@ -222,21 +222,25 @@ export const ListLayout = ({ if (postAny?.type === 'page-intern' && slug) { return ( - + Loading...
}> + + ); } return ( - + Loading...
}> + + ); })() ) : ( diff --git a/packages/ui/src/components/PhotoCard.tsx b/packages/ui/src/components/PhotoCard.tsx index f96117ab..dd9384a3 100644 --- a/packages/ui/src/components/PhotoCard.tsx +++ b/packages/ui/src/components/PhotoCard.tsx @@ -46,6 +46,10 @@ interface PhotoCardProps { imageFit?: 'contain' | 'cover'; className?: string; // Allow custom classes from parent preset?: CardPreset; + showAuthor?: boolean; + showActions?: boolean; + showTitle?: boolean; + showDescription?: boolean; } const PhotoCard = ({ @@ -75,7 +79,11 @@ const PhotoCard = ({ isExternal = false, imageFit = 'contain', className, - preset + preset, + showAuthor = true, + showActions = true, + showTitle = true, + showDescription = true }: PhotoCardProps) => { const { user } = useAuth(); const navigate = useNavigate(); @@ -371,7 +379,7 @@ const PhotoCard = ({
- {showHeader && ( + {showHeader && showAuthor && (
)}
- {!isExternal && ( + {showActions && !isExternal && ( <>
- {!isLikelyFilename(title) &&

{title}

} - {description && ( + {showTitle && !isLikelyFilename(title) &&

{title}

} + {showDescription && description && (
@@ -458,44 +466,46 @@ const PhotoCard = ({
)} -
- - - - {!isExternal && ( - +
+ onClick={(e) => { + e.stopPropagation(); + handleDownload(); + }} + > + + Save + + + + {!isExternal && ( + + )} +
+ )}
)} @@ -506,82 +516,86 @@ const PhotoCard = ({ {/* Row 1: User Avatar (Left) + Actions (Right) */}
{/* User Avatar Block */} - + {showAuthor && ( + + )} {/* Actions */} -
- {!isExternal && ( - <> - - {localLikes > 0 && ( - {localLikes} - )} + {showActions && ( +
+ {!isExternal && ( + <> + + {localLikes > 0 && ( + {localLikes} + )} - - {comments > 0 && ( - {comments} - )} - - )} + + {comments > 0 && ( + {comments} + )} + + )} - - - {!isExternal && ( - - )} - {isOwner && !isExternal && ( - )} -
+ + {!isExternal && ( + + )} + {isOwner && !isExternal && ( + + )} +
+ )}
{/* Likes */} @@ -589,11 +603,11 @@ const PhotoCard = ({ {/* Caption / Description section */}
- {(!isLikelyFilename(title) && title) && ( + {showTitle && (!isLikelyFilename(title) && title) && (
{title}
)} - {description && ( + {showDescription && description && (
diff --git a/packages/ui/src/components/StreamInvalidator.tsx b/packages/ui/src/components/StreamInvalidator.tsx index de009c4c..20635252 100644 --- a/packages/ui/src/components/StreamInvalidator.tsx +++ b/packages/ui/src/components/StreamInvalidator.tsx @@ -1,19 +1,61 @@ import { useEffect } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQueryClient, QueryClient } from '@tanstack/react-query'; import { useStream } from '@/contexts/StreamContext'; -// Mapping of AppEvent.type to React Query Keys -// This acts as a configuration template for all auto-invalidations. -const EVENT_TO_QUERY_KEY: Record = { - 'categories': ['categories'], - 'posts': ['posts'], - 'pages': ['pages'], - 'users': ['users'], - 'organizations': ['organizations'], - 'pictures': ['pictures'], - 'types': ['types'], - 'likes': ['likes'], - 'i18n': ['i18n'], +/** + * Per-item invalidation rules. + * Each handler receives the entity ID (or null for list-level changes) + * and the QueryClient. It decides which React Query keys to invalidate. + */ +const INVALIDATION_RULES: Record void> = { + 'post': (id, qc) => { + if (id) qc.invalidateQueries({ queryKey: ['post', id] }); + qc.invalidateQueries({ queryKey: ['posts'] }); + qc.invalidateQueries({ queryKey: ['pages'] }); // posts can embed in pages + qc.invalidateQueries({ queryKey: ['feed'] }); + }, + 'picture': (id, qc) => { + if (id) qc.invalidateQueries({ queryKey: ['picture', id] }); + qc.invalidateQueries({ queryKey: ['pictures'] }); + qc.invalidateQueries({ queryKey: ['posts'] }); // posts depend on pictures + qc.invalidateQueries({ queryKey: ['pages'] }); // pages depend on pictures + }, + 'pictures': (_id, qc) => { + // Batch operations (upsert/unlink/delete) — no per-item ID + qc.invalidateQueries({ queryKey: ['pictures'] }); + qc.invalidateQueries({ queryKey: ['posts'] }); + qc.invalidateQueries({ queryKey: ['pages'] }); + qc.invalidateQueries({ queryKey: ['feed'] }); + }, + 'page': (id, qc) => { + if (id) qc.invalidateQueries({ queryKey: ['page', id] }); + qc.invalidateQueries({ queryKey: ['pages'] }); + qc.invalidateQueries({ queryKey: ['feed'] }); + }, + 'category': (_id, qc) => { + qc.invalidateQueries({ queryKey: ['categories'] }); + qc.invalidateQueries({ queryKey: ['pages'] }); // pages depend on categories + qc.invalidateQueries({ queryKey: ['feed'] }); + }, + 'type': (_id, qc) => { + qc.invalidateQueries({ queryKey: ['types'] }); + qc.invalidateQueries({ queryKey: ['categories'] }); // categories depend on types + qc.invalidateQueries({ queryKey: ['pages'] }); // → pages + }, + 'layout': (id, qc) => { + if (id) qc.invalidateQueries({ queryKey: ['layout', id] }); + qc.invalidateQueries({ queryKey: ['layouts'] }); + }, + 'glossary': (_id, qc) => { + qc.invalidateQueries({ queryKey: ['i18n'] }); + }, + 'i18n': (_id, qc) => { + qc.invalidateQueries({ queryKey: ['i18n'] }); + }, + 'system': (_id, qc) => { + // Full flush — invalidate everything + qc.invalidateQueries(); + }, }; export const StreamInvalidator = () => { @@ -22,16 +64,15 @@ export const StreamInvalidator = () => { useEffect(() => { const unsubscribe = subscribe((event) => { - // Verify it's a cache invalidation event if (event.kind === 'cache' && event.type) { - const queryKey = EVENT_TO_QUERY_KEY[event.type]; + const handler = INVALIDATION_RULES[event.type]; + const eventId = event.id ?? event.data?.id ?? null; - if (queryKey) { - console.log(`[StreamInvalidator] Invalidating query key: ${queryKey} for event type: ${event.type}`); - queryClient.invalidateQueries({ queryKey }); + if (handler) { + console.log(`[StreamInvalidator] ${event.type}:${eventId ?? '*'}:${event.action}`); + handler(eventId, queryClient); } else { - // Optional: Log unhandled types if you want to verify what's missing - console.log(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`); + console.log(`[StreamInvalidator] Unhandled event type: ${event.type}`); } } }); @@ -39,5 +80,5 @@ export const StreamInvalidator = () => { return unsubscribe; }, [subscribe, queryClient]); - return null; // This component handles logic only, no UI + return null; }; diff --git a/packages/ui/src/components/TopNavigation.tsx b/packages/ui/src/components/TopNavigation.tsx index 5b748ab6..c79f183d 100644 --- a/packages/ui/src/components/TopNavigation.tsx +++ b/packages/ui/src/components/TopNavigation.tsx @@ -1,6 +1,5 @@ import { Link, useLocation } from "react-router-dom"; import { useAuth } from "@/hooks/useAuth"; -import { useOrganization } from "@/contexts/OrganizationContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -16,15 +15,14 @@ import { } from "@/components/ui/dropdown-menu"; import { useNavigate } from "react-router-dom"; import { useProfiles } from "@/contexts/ProfilesContext"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, lazy, Suspense } from "react"; import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n"; -import { CreationWizardPopup } from './CreationWizardPopup'; +const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup }))); import { useCartStore } from "@polymech/ecommerce"; const TopNavigation = () => { const { user, signOut, roles } = useAuth(); const { fetchProfile, profiles } = useProfiles(); - const { orgSlug, isOrgContext } = useOrganization(); const location = useLocation(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); @@ -33,7 +31,7 @@ const TopNavigation = () => { const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext(); const cartItemCount = useCartStore((s) => s.itemCount); - const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth'; + const authPath = '/auth'; useEffect(() => { if (user?.id) { @@ -295,12 +293,16 @@ const TopNavigation = () => {
- setCreationWizardOpen(false)} - preloadedImages={wizardInitialImage ? [wizardInitialImage] : []} - initialMode={creationWizardMode} - /> + {user && creationWizardOpen && ( + + setCreationWizardOpen(false)} + preloadedImages={wizardInitialImage ? [wizardInitialImage] : []} + initialMode={creationWizardMode} + /> + + )} ); }; diff --git a/packages/ui/src/components/UploadModal.tsx b/packages/ui/src/components/UploadModal.tsx deleted file mode 100644 index aea65f4f..00000000 --- a/packages/ui/src/components/UploadModal.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { supabase } from '@/integrations/supabase/client'; -import { createPicture } from '@/modules/posts/client-pictures'; -import { useAuth } from '@/hooks/useAuth'; -import { useToast } from '@/hooks/use-toast'; -import { Upload, X } from 'lucide-react'; -import MarkdownEditor from '@/components/MarkdownEditor'; -import { useOrganization } from '@/contexts/OrganizationContext'; -import { uploadImage } from '@/lib/uploadUtils'; - -const uploadSchema = z.object({ - title: z.string().max(100, 'Title must be less than 100 characters').optional(), - description: z.string().max(1000, 'Description must be less than 1000 characters').optional(), - file: z.any().refine((file) => file && file.length > 0, 'Please select a file'), -}); - -type UploadFormData = z.infer; - -interface UploadModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onUploadSuccess: () => void; -} - -const UploadModal = ({ open, onOpenChange, onUploadSuccess }: UploadModalProps) => { - const [uploading, setUploading] = useState(false); - const [previewUrl, setPreviewUrl] = useState(null); - const { user } = useAuth(); - const { toast } = useToast(); - const { orgSlug, isOrgContext } = useOrganization(); - - const form = useForm({ - resolver: zodResolver(uploadSchema), - defaultValues: { - title: '', - description: '', - }, - }); - - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const url = URL.createObjectURL(file); - setPreviewUrl(url); - form.setValue('file', event.target.files); - } - }; - - const onSubmit = async (data: UploadFormData) => { - if (!user) return; - - setUploading(true); - try { - const file = data.file[0]; - // Upload file to storage (direct or via proxy) - const { publicUrl } = await uploadImage(file, user.id); - - - // Save picture metadata via API - await createPicture({ - user_id: user.id, - title: data.title?.trim() || null, - description: data.description || null, - image_url: publicUrl - }); - - toast({ - title: "Picture uploaded successfully!", - description: "Your picture has been shared with the community.", - }); - - form.reset(); - setPreviewUrl(null); - onOpenChange(false); - onUploadSuccess(); - } catch (error: any) { - toast({ - title: "Upload failed", - description: error.message, - variant: "destructive", - }); - } finally { - setUploading(false); - } - }; - - const handleClose = () => { - if (previewUrl) { - URL.revokeObjectURL(previewUrl); - setPreviewUrl(null); - } - form.reset(); - onOpenChange(false); - }; - - return ( - - - - - - Upload Picture - - -
- - ( - - Picture - -
- - {previewUrl && ( -
- Preview - -
- )} -
-
- -
- )} - /> - ( - - Title (Optional) - - - - - - )} - /> - ( - - Description (Optional) - - - - - - )} - /> -
- - -
- - -
-
- ); -}; - -export default UploadModal; \ No newline at end of file diff --git a/packages/ui/src/components/VideoCard.tsx b/packages/ui/src/components/VideoCard.tsx index b685ad60..4c843483 100644 --- a/packages/ui/src/components/VideoCard.tsx +++ b/packages/ui/src/components/VideoCard.tsx @@ -5,7 +5,7 @@ import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import React, { useState, useEffect, useRef } from "react"; import MarkdownRenderer from "@/components/MarkdownRenderer"; -import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default'; + import { useNavigate, useLocation } from "react-router-dom"; import { T, translate } from "@/i18n"; import type { MuxResolution } from "@/types"; @@ -462,9 +462,7 @@ const VideoCard = ({ controls playsInline className={`w-full ${variant === 'grid' ? "h-full" : ""}`} - layoutProps={{ - icons: defaultLayoutIcons - }} + layoutProps={{}} /> )} diff --git a/packages/ui/src/components/admin/AclEditor.tsx b/packages/ui/src/components/admin/AclEditor.tsx index 97b28aca..905df4e6 100644 --- a/packages/ui/src/components/admin/AclEditor.tsx +++ b/packages/ui/src/components/admin/AclEditor.tsx @@ -5,6 +5,7 @@ import { UserPicker } from "./UserPicker"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Trash2, AlertCircle, Check, Loader2, Shield, Globe, Users } from "lucide-react"; import { toast } from "sonner"; +import { T, translate } from "@/i18n"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; @@ -114,7 +115,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) if (next.length === 0) { try { await revokeAclPermission(resourceType, mount, { path, userId: ANONYMOUS_USER_ID }); - toast.success("Anonymous access revoked"); + toast.success(translate("Anonymous access revoked")); fetchAcl(); } catch (e: any) { toast.error(e.message); } return; @@ -138,16 +139,16 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) path, userId: ANONYMOUS_USER_ID, }); - toast.success("Anonymous access revoked"); + toast.success(translate("Anonymous access revoked")); setAnonPerms(['read', 'list']); // reset defaults } else { - if (anonPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAnon(false); return; } + if (anonPerms.length === 0) { toast.error(translate('Select at least one permission')); setTogglingAnon(false); return; } await grantAclPermission(resourceType, mount, { path, userId: ANONYMOUS_USER_ID, permissions: anonPerms, }); - toast.success("Anonymous access enabled"); + toast.success(translate("Anonymous access enabled")); } fetchAcl(); } catch (e: any) { @@ -164,7 +165,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) if (next.length === 0) { try { await revokeAclPermission(resourceType, mount, { path, userId: AUTHENTICATED_USER_ID }); - toast.success("Authenticated access revoked"); + toast.success(translate("Authenticated access revoked")); fetchAcl(); } catch (e: any) { toast.error(e.message); } return; @@ -188,16 +189,16 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) path, userId: AUTHENTICATED_USER_ID, }); - toast.success("Authenticated access revoked"); + toast.success(translate("Authenticated access revoked")); setAuthPerms(['read', 'list']); } else { - if (authPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAuth(false); return; } + if (authPerms.length === 0) { toast.error(translate('Select at least one permission')); setTogglingAuth(false); return; } await grantAclPermission(resourceType, mount, { path, userId: AUTHENTICATED_USER_ID, permissions: authPerms, }); - toast.success("Authenticated access enabled"); + toast.success(translate("Authenticated access enabled")); } fetchAcl(); } catch (e: any) { @@ -217,7 +218,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) userId: selectedUser, permissions: Array.from(userPerms), }); - toast.success("Access granted"); + toast.success(translate("Access granted")); fetchAcl(); setSelectedUser(""); } catch (e: any) { @@ -228,14 +229,14 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) }; const handleRevoke = async (entry: AclEntry) => { - if (!confirm("Are you sure you want to revoke this permission?")) return; + if (!confirm(translate("Are you sure you want to revoke this permission?"))) return; try { await revokeAclPermission(resourceType, mount, { path: entry.path, userId: entry.userId, group: entry.group, }); - toast.success("Access revoked"); + toast.success(translate("Access revoked")); fetchAcl(); } catch (e: any) { toast.error(e.message); @@ -254,10 +255,10 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) - Access Control + Access Control - Manage permissions for {mount}:{path} + Manage permissions for {mount}:{path} @@ -268,9 +269,9 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
-

Anonymous Access

+

Anonymous Access

- Allow unauthenticated access on {path} + Allow unauthenticated access on {path}

@@ -291,9 +292,9 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
-

Authenticated Users

+

Authenticated Users

- Allow any logged-in user access on {path} + Allow any logged-in user access on {path}

@@ -310,14 +311,14 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) {/* Grant Form */}
-

Grant Access

+

Grant Access

setSelectedUser(id)} />
@@ -325,14 +326,14 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) {/* ACL List */}
-

Active Permissions (Mount: {mount})

+

Active Permissions (Mount: {mount})

- Path - Subject - Permissions + Path + Subject + Permissions @@ -346,7 +347,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) ) : sortedEntries.length === 0 ? ( - No active permissions found. + No active permissions found. ) : ( @@ -354,13 +355,13 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps) {entry.path || '/'} - {entry.path === path && Current} + {entry.path === path && Current} {entry.userId === ANONYMOUS_USER_ID ? (
- Anonymous + Anonymous
) : entry.userId ? (
diff --git a/packages/ui/src/components/admin/BansManager.tsx b/packages/ui/src/components/admin/BansManager.tsx index 9a032d0f..863e9374 100644 --- a/packages/ui/src/components/admin/BansManager.tsx +++ b/packages/ui/src/components/admin/BansManager.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { Shield, Trash2, RefreshCw } from "lucide-react"; +import { T, translate } from "@/i18n"; import { Table, TableBody, @@ -54,7 +55,7 @@ export const BansManager = ({ session }: { session: any }) => { const data = await res.json(); setBanList(data); } catch (err: any) { - toast.error("Failed to fetch ban list", { + toast.error(translate("Failed to fetch ban list"), { description: err.message }); } finally { @@ -90,17 +91,17 @@ export const BansManager = ({ session }: { session: any }) => { const data = await res.json(); if (data.success) { - toast.success("Unbanned successfully", { + toast.success(translate("Unbanned successfully"), { description: data.message }); fetchBanList(); } else { - toast.warning("Not found", { + toast.warning(translate("Not found"), { description: data.message }); } } catch (err: any) { - toast.error("Failed to unban", { + toast.error(translate("Failed to unban"), { description: err.message }); } finally { @@ -119,18 +120,18 @@ export const BansManager = ({ session }: { session: any }) => {
-

Ban Management

+

Ban Management

- Banned IPs + Banned IPs
{banList.bannedIPs.length}
@@ -138,7 +139,7 @@ export const BansManager = ({ session }: { session: any }) => {
- Banned Users + Banned Users
{banList.bannedUserIds.length}
@@ -146,7 +147,7 @@ export const BansManager = ({ session }: { session: any }) => {
- Banned Tokens + Banned Tokens
{banList.bannedTokens.length}
@@ -157,7 +158,7 @@ export const BansManager = ({ session }: { session: any }) => { {totalBans === 0 ? ( - No active bans + No active bans ) : ( @@ -165,17 +166,17 @@ export const BansManager = ({ session }: { session: any }) => { {banList.bannedIPs.length > 0 && ( - Banned IP Addresses + Banned IP Addresses - IP addresses that have been auto-banned for excessive requests + IP addresses that have been auto-banned for excessive requests
- IP Address - Actions + IP Address + Actions @@ -189,7 +190,7 @@ export const BansManager = ({ session }: { session: any }) => { onClick={() => setUnbanTarget({ type: 'ip', value: ip })} > - Unban + Unban @@ -203,17 +204,17 @@ export const BansManager = ({ session }: { session: any }) => { {banList.bannedUserIds.length > 0 && ( - Banned Users + Banned Users - User accounts that have been auto-banned for excessive requests + User accounts that have been auto-banned for excessive requests
- User ID - Actions + User ID + Actions @@ -227,7 +228,7 @@ export const BansManager = ({ session }: { session: any }) => { onClick={() => setUnbanTarget({ type: 'user', value: userId })} > - Unban + Unban @@ -241,16 +242,16 @@ export const BansManager = ({ session }: { session: any }) => { {banList.bannedTokens.length > 0 && ( - Banned Tokens + Banned Tokens - Authentication tokens that have been auto-banned (cannot be unbanned via UI) + Authentication tokens that have been auto-banned (cannot be unbanned via UI)
- Token (truncated) + Token (truncated) @@ -272,7 +273,7 @@ export const BansManager = ({ session }: { session: any }) => { !open && setUnbanTarget(null)}> - Confirm Unban + Confirm Unban Are you sure you want to unban this {unbanTarget?.type}?
@@ -281,8 +282,8 @@ export const BansManager = ({ session }: { session: any }) => { - Cancel - Unban + Cancel + Unban diff --git a/packages/ui/src/components/admin/StorageManager.tsx b/packages/ui/src/components/admin/StorageManager.tsx index f6e57ee3..f2b1bbef 100644 --- a/packages/ui/src/components/admin/StorageManager.tsx +++ b/packages/ui/src/components/admin/StorageManager.tsx @@ -3,6 +3,7 @@ import { FileBrowserWidget } from "@/modules/storage"; import { AclEditor } from "@/components/admin/AclEditor"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { T } from "@/i18n"; export default function StorageManager() { // Default to 'root' mount, but we could allow selecting mounts if there are multiple. @@ -20,7 +21,7 @@ export default function StorageManager() { {/* Left Pane: File Browser */}
-

File Browser

+

File Browser

-

Permissions

+

Permissions

-
Selected Path
+
Selected Path
{mount}:{targetPath}
diff --git a/packages/ui/src/components/admin/UserManager.tsx b/packages/ui/src/components/admin/UserManager.tsx index 9392c287..28b2bfc4 100644 --- a/packages/ui/src/components/admin/UserManager.tsx +++ b/packages/ui/src/components/admin/UserManager.tsx @@ -69,17 +69,17 @@ const UserManager = () => {
- User - Username - Joined Date - Actions + User + Username + Joined Date + Actions @@ -115,12 +115,12 @@ const UserManager = () => { - setEditingUser(user)}>Edit + setEditingUser(user)}>Edit setDeletingUser(user)} > - Delete + Delete diff --git a/packages/ui/src/components/admin/ViolationsMonitor.tsx b/packages/ui/src/components/admin/ViolationsMonitor.tsx index 7ba53a5c..e593d64c 100644 --- a/packages/ui/src/components/admin/ViolationsMonitor.tsx +++ b/packages/ui/src/components/admin/ViolationsMonitor.tsx @@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { AlertTriangle, RefreshCw } from "lucide-react"; +import { T, translate } from "@/i18n"; import { Table, TableBody, @@ -48,7 +49,7 @@ export const ViolationsMonitor = ({ session }: { session: any }) => { const data = await res.json(); setStats(data); } catch (err: any) { - toast.error("Failed to fetch violation stats", { + toast.error(translate("Failed to fetch violation stats"), { description: err.message }); } finally { @@ -88,19 +89,19 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
-

Violation Monitor

+

Violation Monitor

- Active Violations - Currently tracked violation records + Active Violations + Currently tracked violation records
{stats.totalViolations}
@@ -108,13 +109,13 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
- Auto-Refresh - Updates every 5 seconds + Auto-Refresh + Updates every 5 seconds
- Live monitoring + Live monitoring
@@ -123,26 +124,26 @@ export const ViolationsMonitor = ({ session }: { session: any }) => { {stats.totalViolations === 0 ? ( - No active violations + No active violations ) : ( - Violation Records + Violation Records - Entities approaching the ban threshold (5 violations within the configured window) + Entities approaching the ban threshold (5 violations within the configured window)
- Type - Identifier - Count - First Violation - Last Violation + Type + Identifier + Count + First Violation + Last Violation @@ -180,18 +181,17 @@ export const ViolationsMonitor = ({ session }: { session: any }) => { - About Violations + About Violations

- Violation Tracking: The system tracks rate limit violations for IPs and authenticated users. + Violation Tracking: The system tracks rate limit violations for IPs and authenticated users.

- Auto-Ban Threshold: When an entity reaches 5 violations within the configured time window, - they are automatically banned and moved to the ban list. + Auto-Ban Threshold: When an entity reaches 5 violations within the configured time window, they are automatically banned and moved to the ban list.

- Cleanup: Violation records are automatically cleaned up after the time window expires. + Cleanup: Violation records are automatically cleaned up after the time window expires.

diff --git a/packages/ui/src/components/filters/ProviderSelector.tsx b/packages/ui/src/components/filters/ProviderSelector.tsx index 4115660d..af5ce63e 100644 --- a/packages/ui/src/components/filters/ProviderSelector.tsx +++ b/packages/ui/src/components/filters/ProviderSelector.tsx @@ -24,6 +24,7 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { Loader2, Zap, Globe, Brain, Server, Settings, Check, ChevronsUpDown } from 'lucide-react'; + import { cn } from '@/lib/utils'; interface ProviderSelectorProps { @@ -109,7 +110,7 @@ export const ProviderSelector: React.FC = ({ const handleProviderChange = (newProvider: string) => { onProviderChange(newProvider); - + // Reset model when provider changes const models = getCurrentModels(newProvider); if (models.length > 0) { @@ -236,8 +237,8 @@ export const ProviderSelector: React.FC = ({ - ); } diff --git a/packages/ui/src/components/playground/I18nPlayground.tsx b/packages/ui/src/components/playground/I18nPlayground.tsx index ddf3f22e..67176fe3 100644 --- a/packages/ui/src/components/playground/I18nPlayground.tsx +++ b/packages/ui/src/components/playground/I18nPlayground.tsx @@ -1,12 +1,22 @@ -import React, { useState, useEffect } from 'react'; -import { translateText, fetchGlossaries, createGlossary, deleteGlossary, TargetLanguageCodeSchema, Glossary } from '@/modules/i18n/client-i18n'; +import React, { useState, useEffect, useRef } from 'react'; +import { + translateText, fetchGlossaries, createGlossary, deleteGlossary, + fetchGlossaryTerms, updateGlossaryTerms, + TargetLanguageCodeSchema, Glossary, + WidgetTranslation, fetchWidgetTranslations, fetchTranslationGaps, +} from '@/modules/i18n/client-i18n'; +import { WidgetTranslationPanel, type WidgetTranslationPanelHandle } from '@/modules/i18n/WidgetTranslationPanel'; +import { getRequestedTerms } from '@/i18n'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Loader2, Trash2, Plus } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Loader2, Trash2, Plus, Search, Pencil, Save, X, Languages, Import, ChevronDown, ChevronRight, FileUp, Download, BookPlus } from 'lucide-react'; +import { Checkbox } from '@/components/ui/checkbox'; import { toast } from 'sonner'; +import { useAppConfig } from '@/hooks/useSystemInfo'; // Safely extract options from ZodUnion of ZodEnums const TARGET_LANGS = [ @@ -15,8 +25,11 @@ const TARGET_LANGS = [ ].sort(); export default function I18nPlayground() { + const appConfig = useAppConfig(); + const srcLangDefault = appConfig?.i18n?.source_language || 'en'; + // Translation State - const [srcLang, setSrcLang] = useState('en'); + const [srcLang, setSrcLang] = useState(srcLangDefault); const [dstLang, setDstLang] = useState('fr'); const [text, setText] = useState(''); const [translation, setTranslation] = useState(''); @@ -29,10 +42,30 @@ export default function I18nPlayground() { // New Glossary State const [newGlossaryName, setNewGlossaryName] = useState(''); - const [newGlossarySrc, setNewGlossarySrc] = useState('en'); + const [newGlossarySrc, setNewGlossarySrc] = useState(srcLangDefault); const [newGlossaryDst, setNewGlossaryDst] = useState('fr'); const [newGlossaryEntries, setNewGlossaryEntries] = useState(''); // CSV format: term,translation + // Glossary Term Editor State + const [expandedGlossaryId, setExpandedGlossaryId] = useState(null); + const [glossaryTerms, setGlossaryTerms] = useState>({}); + const [glossaryTermsOriginal, setGlossaryTermsOriginal] = useState>({}); + const [glossaryTermsLoading, setGlossaryTermsLoading] = useState(false); + const [glossaryTermsSaving, setGlossaryTermsSaving] = useState(false); + const [newTermKey, setNewTermKey] = useState(''); + const [newTermValue, setNewTermValue] = useState(''); + + // Widget Translation State (kept for import-from-i18n feature) + const [wtList, setWtList] = useState([]); + const [wtImportTargetLang, setWtImportTargetLang] = useState('de'); + const panelRef = useRef(null); + + // Missing Translations State + const [mtEntityType, setMtEntityType] = useState('category'); + const [mtTargetLang, setMtTargetLang] = useState('de'); + const [mtLoading, setMtLoading] = useState(false); + const [mtIncludeOutdated, setMtIncludeOutdated] = useState(true); + useEffect(() => { loadGlossaries(); }, []); @@ -71,7 +104,7 @@ export default function I18nPlayground() { const parts = line.split(','); if (parts.length >= 2) { const term = parts[0].trim(); - const trans = parts.slice(1).join(',').trim(); // Handle commas in translation? Simple CSV logic. + const trans = parts.slice(1).join(',').trim(); if (term && trans) entries[term] = trans; } }); @@ -103,6 +136,178 @@ export default function I18nPlayground() { } }; + // --- Glossary Term Editor Handlers --- + + const toggleGlossaryExpand = async (glossaryId: string) => { + if (expandedGlossaryId === glossaryId) { + setExpandedGlossaryId(null); + return; + } + setExpandedGlossaryId(glossaryId); + setGlossaryTermsLoading(true); + try { + const terms = await fetchGlossaryTerms(glossaryId); + setGlossaryTerms({ ...terms }); + setGlossaryTermsOriginal({ ...terms }); + } catch (e: any) { + toast.error(`Failed to load terms: ${e.message}`); + } finally { + setGlossaryTermsLoading(false); + } + }; + + const handleTermChange = (oldKey: string, field: 'term' | 'translation', value: string) => { + setGlossaryTerms(prev => { + const updated = { ...prev }; + if (field === 'term') { + const oldVal = updated[oldKey]; + delete updated[oldKey]; + updated[value] = oldVal; + } else { + updated[oldKey] = value; + } + return updated; + }); + }; + + const handleAddTerm = () => { + if (!newTermKey.trim()) return; + setGlossaryTerms(prev => ({ ...prev, [newTermKey.trim()]: newTermValue.trim() })); + setNewTermKey(''); + setNewTermValue(''); + }; + + const handleDeleteTerm = (key: string) => { + setGlossaryTerms(prev => { + const updated = { ...prev }; + delete updated[key]; + return updated; + }); + }; + + const glossaryTermsDirty = (() => { + 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]); + })(); + + const handleSaveGlossaryTerms = async () => { + if (!expandedGlossaryId) return; + setGlossaryTermsSaving(true); + try { + const res = await updateGlossaryTerms(expandedGlossaryId, glossaryTerms); + toast.success(`Saved ${res.entry_count} terms to DeepL + DB`); + setGlossaryTermsOriginal({ ...glossaryTerms }); + loadGlossaries(); // refresh entry counts + } catch (e: any) { + toast.error(`Save failed: ${e.message}`); + } finally { + setGlossaryTermsSaving(false); + } + }; + + // --- Widget Translations Handlers --- + + const handleWtImportFromI18n = async () => { + const allTerms = getRequestedTerms() as { [lang: string]: Record }; + + // 1. Fetch existing DB translations for this target lang + let dbItems: WidgetTranslation[] = []; + try { + dbItems = await fetchWidgetTranslations({ targetLang: wtImportTargetLang }); + } catch (e) { + console.error('Failed to fetch existing translations', e); + } + + // 2. Build new entries from i18n localStorage + // Use source language config as key source since it holds ALL keys; look up translations from target lang + const sourceTerms = allTerms[srcLangDefault] || {}; + const targetTerms = allTerms[wtImportTargetLang] || {}; + const existingKeys = new Set(dbItems.map(wt => wt.prop_path)); + const newEntries: WidgetTranslation[] = Object.entries(sourceTerms) + .filter(([key]) => !existingKeys.has(key)) + .map(([key, _sourceText], i) => { + const translated = targetTerms[key]; + const hasTranslation = translated && translated !== key; + return { + id: `local-${Date.now()}-${i}`, + entity_type: 'system', + entity_id: null, + widget_id: null, + prop_path: key, + source_lang: srcLangDefault, + target_lang: wtImportTargetLang, + source_text: key, + translated_text: hasTranslation ? translated : '', + status: hasTranslation ? 'machine' : 'draft', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as WidgetTranslation; + }); + + // 3. Merge: DB items + new i18n items + const merged = [...dbItems, ...newEntries]; + panelRef.current?.replaceList(merged); + toast.success(`Showing ${dbItems.length} existing + ${newEntries.length} new ${wtImportTargetLang} terms.`); + }; + + const handleFetchMissing = async () => { + setMtLoading(true); + try { + const missing = await fetchTranslationGaps(mtEntityType, mtTargetLang, undefined, mtIncludeOutdated ? 'all' : 'missing'); + if (missing.length === 0) { + toast.info(`No missing ${mtEntityType} translations for ${mtTargetLang}`); + return; + } + // Add synthetic IDs so the panel can track them + const withIds = missing.map((item, i) => ({ + ...item, + id: `missing-${Date.now()}-${i}`, + })) as WidgetTranslation[]; + panelRef.current?.replaceList(withIds); + toast.success(`Found ${withIds.length} missing ${mtEntityType} translations for ${mtTargetLang}`); + } catch (e: any) { + toast.error(`Failed: ${e.message}`); + } finally { + setMtLoading(false); + } + }; + + const handleAddToGlossary = async (item: WidgetTranslation) => { + if (!item.source_text || !item.translated_text) return; + + const srcLangUpper = (item.source_lang || 'en').toUpperCase(); + const tgtLangUpper = (item.target_lang || 'en').toUpperCase(); + + try { + // Find matching glossary + let glossary = glossaries.find( + g => g.source_lang.toUpperCase() === srcLangUpper && g.target_lang.toUpperCase() === tgtLangUpper + ); + + if (!glossary) { + // Create a new one + const name = `Auto ${srcLangUpper}→${tgtLangUpper}`; + await createGlossary(name, srcLangUpper, tgtLangUpper, { + [item.source_text]: item.translated_text, + }); + await loadGlossaries(); + toast.success(`Created glossary "${name}" with 1 entry`); + return; + } + + // Fetch existing terms, merge, update + const existingTerms = await fetchGlossaryTerms(glossary.glossary_id); + existingTerms[item.source_text] = item.translated_text; + await updateGlossaryTerms(glossary.glossary_id, existingTerms); + await loadGlossaries(); + toast.success(`Added "${item.source_text}" → "${item.translated_text}" to ${glossary.name}`); + } catch (e: any) { + toast.error(`Failed to add to glossary: ${e.message}`); + } + }; + return (

i18n / DeepL Playground

@@ -143,10 +348,13 @@ export default function I18nPlayground() { None {glossaries - .filter(g => g.source_lang === srcLang && g.target_lang === dstLang) + .filter(g => + (g.source_lang === srcLang && g.target_lang === dstLang) || + (g.source_lang === dstLang && g.target_lang === srcLang) + ) .map(g => ( - {g.name} ({g.entry_count} entries) + {g.name} ({g.source_lang}→{g.target_lang}, {g.entry_count} entries) ))} @@ -181,26 +389,121 @@ export default function I18nPlayground() { {/* List */} -
+
{loadingGlossaries ? ( ) : glossaries.length === 0 ? (

No glossaries found.

) : ( - glossaries.map(g => ( -
-
-

{g.name}

-

{g.source_lang} -> {g.target_lang} • {g.entry_count} entries

+ glossaries.map(g => { + const isExpanded = expandedGlossaryId === g.glossary_id; + return ( +
+
toggleGlossaryExpand(g.glossary_id)}> +
+ {isExpanded ? : } +
+

{g.name}

+

{g.source_lang} -> {g.target_lang} • {g.entry_count} entries

+
+
+ +
+ + {isExpanded && ( +
+ {glossaryTermsLoading ? ( +
+ ) : ( + <> +
+ + + + + + + + + {Object.entries(glossaryTerms).map(([term, translation]) => ( + + + + + + ))} + {/* Add new term row */} + + + + + + +
Term ({g.source_lang})Translation ({g.target_lang})
+ handleTermChange(term, 'term', e.target.value)} + className="h-7 text-xs" + /> + + handleTermChange(term, 'translation', e.target.value)} + className="h-7 text-xs" + /> + + +
+ setNewTermKey(e.target.value)} + className="h-7 text-xs" + onKeyDown={e => e.key === 'Enter' && handleAddTerm()} + /> + + setNewTermValue(e.target.value)} + className="h-7 text-xs" + onKeyDown={e => e.key === 'Enter' && handleAddTerm()} + /> + + +
+ +
+ + {Object.keys(glossaryTerms).length} terms + {glossaryTermsDirty && • modified} + + +
+ + )} +
+ )}
- -
- )) + ); + }) )} +

Create New Glossary

@@ -220,9 +523,123 @@ export default function I18nPlayground() { Create Glossary
- - -
- +
+
+ + + {/* Widget Translations Section — segmented by task */} + + + Widget Translations + + + + + + DB Search + + + Import i18n + + + Fetch Missing + + + + {/* Tab 1: DB Search — uses panel's built-in filter bar */} + +

+ Search existing widget translations in the database using the filter bar below. +

+
+ + {/* Tab 2: Import from browser i18n localStorage */} + +
+ Target: + + + + Load terms from browser i18n localStorage and merge with DB entries. + +
+
+ + {/* Tab 3: Fetch Missing translations from server */} + +
+ Type: + + + + + +
+
+
+ + {/* Shared Panel — always visible below the tabs */} + +
+
+ ); } + diff --git a/packages/ui/src/components/variables/VariableBuilder.tsx b/packages/ui/src/components/variables/VariableBuilder.tsx index 4ac1862f..663220f0 100644 --- a/packages/ui/src/components/variables/VariableBuilder.tsx +++ b/packages/ui/src/components/variables/VariableBuilder.tsx @@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Type as TypeIcon, Hash, ToggleLeft, Trash2, Lock, Plus, Import, Download, ChevronDown, Copy, FileJson } from 'lucide-react'; import { toast } from "sonner"; +import { T, translate } from '@/i18n'; export interface VariableElement { id: string; @@ -56,11 +57,11 @@ const CanvasElement = ({
- {element.key || 'New Variable'} + {element.key || translate('New Variable')}
{element.type} - {element.type === 'secret' ? '••••••••' : (element.value || '(empty)')} + {element.type === 'secret' ? '••••••••' : (element.value || translate('(empty)'))}
@@ -80,13 +81,13 @@ const CanvasElement = ({ e.stopPropagation()}> - Delete Variable? + Delete Variable? Are you sure you want to delete variable "{element.key}"? This action cannot be undone. - Cancel + Cancel { e.stopPropagation(); @@ -95,7 +96,7 @@ const CanvasElement = ({ }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - Delete + Delete @@ -305,10 +306,10 @@ const VariableBuilderContent = ({ setElements(prev => [...prev, ...newElements]); setImportJson(''); setIsImportOpen(false); - toast.success(`Imported ${newElements.length} variables`); + toast.success(translate(`Imported ${newElements.length} variables`)); } catch (error) { console.error("Import failed", error); - toast.error("Invalid JSON format"); + toast.error(translate("Invalid JSON format")); } }; @@ -326,7 +327,7 @@ const VariableBuilderContent = ({ } }); navigator.clipboard.writeText(JSON.stringify(data, null, 2)); - toast.success("Copied to clipboard"); + toast.success(translate("Copied to clipboard")); }; const handleExportFile = () => { @@ -351,7 +352,7 @@ const VariableBuilderContent = ({ a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - toast.success("Download started"); + toast.success(translate("Download started")); }; return ( @@ -359,7 +360,7 @@ const VariableBuilderContent = ({ {/* Top Toolbar: Palette & Search & Actions */}
- Variables + Variables
- Import Variables (JSON) + Import Variables (JSON) - Paste a JSON object {`{"KEY": "VALUE"}`} to import variables. + Paste a JSON object to import variables.