From 41fbfd2369938c3ca6ce2c616e33baa5c1fc376b Mon Sep 17 00:00:00 2001 From: Babayaga Date: Thu, 19 Feb 2026 09:24:43 +0100 Subject: [PATCH] search 1/2 --- packages/ui/docs/i18n.md | 414 ++++++++++++ packages/ui/docs/page-commands.md | 87 +++ packages/ui/docs/page-edit.md | 84 +++ packages/ui/docs/tabs.md | 95 +++ packages/ui/docs/type-system.md | 210 ++++++ packages/ui/docs/types.md | 11 +- packages/ui/src/App.tsx | 3 + .../ui/src/components/CategoryTreeView.tsx | 125 ++++ packages/ui/src/components/ImageLightbox.tsx | 99 ++- packages/ui/src/components/ListLayout.tsx | 14 +- packages/ui/src/components/MediaCard.tsx | 6 +- packages/ui/src/components/PhotoGrid.tsx | 11 +- .../components/widgets/ImagePickerDialog.tsx | 2 +- packages/ui/src/hooks/useResponsiveImage.ts | 4 +- .../src/modules/pages/FileBrowserWidget.tsx | 14 +- packages/ui/src/modules/pages/PageActions.tsx | 20 +- packages/ui/src/modules/pages/PageCard.tsx | 181 ++--- packages/ui/src/modules/pages/UserPage.tsx | 3 +- .../modules/pages/editor/UserPageDetails.tsx | 3 + .../src/modules/pages/editor/UserPageEdit.tsx | 2 +- .../ui/src/modules/posts/client-pictures.ts | 5 +- .../ui/src/modules/search/client-search.ts | 31 + packages/ui/src/pages/Index.tsx | 270 +++++--- packages/ui/src/pages/Post.tsx | 6 +- .../pages/Post/renderers/CompactRenderer.tsx | 2 + .../components/CompactPostHeader.tsx | 17 +- packages/ui/src/pages/SearchResults.tsx | 636 ++++-------------- 27 files changed, 1647 insertions(+), 708 deletions(-) create mode 100644 packages/ui/docs/i18n.md create mode 100644 packages/ui/docs/page-commands.md create mode 100644 packages/ui/docs/page-edit.md create mode 100644 packages/ui/docs/tabs.md create mode 100644 packages/ui/docs/type-system.md create mode 100644 packages/ui/src/components/CategoryTreeView.tsx create mode 100644 packages/ui/src/modules/search/client-search.ts diff --git a/packages/ui/docs/i18n.md b/packages/ui/docs/i18n.md new file mode 100644 index 00000000..8f347467 --- /dev/null +++ b/packages/ui/docs/i18n.md @@ -0,0 +1,414 @@ +# i18n — Content Translation & Versioning + +> Proposal for translating pages, widgets, and other content types with version tracking. + +--- + +## Status Quo + +| What exists | Where | +|---|---| +| **`i18n_translations`** — flat `src_text → dst_text` cache | `db-i18n.ts` | +| **`i18n_glossaries` / `i18n_glossary_terms`** — DeepL glossary sync | `db-i18n.ts` | +| **DeepL server-side translate** — translate + cache in one call | `i18n-deepl.ts` | +| **`@polymech/i18n`** — shared `clean()` helper etc. | monorepo package | + +The existing system translates **arbitrary text blobs**. It has no awareness of: + +- **Which page / widget** a translation belongs to +- **Which version** of the source content was translated +- **Structural identity** — if a widget moves or is deleted, orphaned translations linger + +--- + +## Goals + +1. **Page-level translations** — a translated "snapshot" of an entire page +2. **Widget-level translations** — translate individual widget text props independently +3. **Content versioning** — track which source version a translation was produced from, detect drift +4. **Reuse existing infra** — `i18n_translations` stays as the text cache, DeepL stays as the engine + +--- + +## Proposed Database Schema + +### 1. `content_versions` + +Tracks every published snapshot of any content entity (pages, posts, collections, …). + +```sql +create table content_versions ( + id uuid primary key default gen_random_uuid(), + entity_type text not null, -- 'page' | 'post' | 'collection' + entity_id uuid not null, -- pages.id / posts.id / … + version int not null default 1, -- monotonic per entity + content_hash text not null, -- sha256 of JSON content + content jsonb, -- snapshot of content at this version (optional, for rollback) + meta jsonb default '{}', -- { author, change_note, … } + created_at timestamptz default now(), + created_by uuid references auth.users(id), + + unique (entity_type, entity_id, version) +); + +create index idx_cv_entity on content_versions (entity_type, entity_id); +``` + +> **Why a separate table?** +> The `pages` table stores the *current* working state. +> `content_versions` stores immutable snapshots you can diff, rollback, or translate against. + +--- + +### 2. `content_translations` + +Links a translated content blob to a specific source version + language. + +```sql +create type translation_status as enum ('draft', 'machine', 'reviewed', 'published'); + +create table content_translations ( + id uuid primary key default gen_random_uuid(), + entity_type text not null, + entity_id uuid not null, + source_version int not null, -- FK-like ref to content_versions.version + source_lang text not null default 'de', + target_lang text not null, + status translation_status default 'draft', + + -- Translated payload (same shape as source content) + translated_content jsonb, -- full page JSON with translated strings + + -- Drift detection + source_hash text, -- hash of source at translation time + is_stale boolean default false, -- set true when source gets a newer version + + meta jsonb default '{}', -- { translator, provider, cost, … } + created_at timestamptz default now(), + updated_at timestamptz default now(), + translated_by uuid references auth.users(id), + + unique (entity_type, entity_id, source_version, target_lang) +); + +create index idx_ct_entity on content_translations (entity_type, entity_id, target_lang); +``` + +--- + +### 3. `widget_translations` *(optional — granular level)* + +For widget-by-widget translation without duplicating the whole page JSON. + +```sql +create table widget_translations ( + id uuid primary key default gen_random_uuid(), + entity_type text not null default 'page', + entity_id uuid not null, + widget_id text not null, -- WidgetInstance.id from the JSON tree + prop_path text not null default 'content', -- e.g. 'content', 'label', 'placeholder' + source_lang text not null, + target_lang text not null, + source_text text not null, + translated_text text not null, + source_version int, -- which content_version this was derived from + status translation_status default 'machine', + meta jsonb default '{}', + created_at timestamptz default now(), + updated_at timestamptz default now(), + + unique (entity_type, entity_id, widget_id, prop_path, target_lang) +); + +create index idx_wt_entity on widget_translations (entity_type, entity_id, target_lang); +``` + +> **Why both `content_translations` and `widget_translations`?** +> - `content_translations` = "give me the whole page in French" (fast serve) +> - `widget_translations` = "give me just widget X in French" (granular edit, partial retranslation) +> When serving, we prefer `content_translations` (single read). When editing, we use `widget_translations` for surgical updates. + +--- + +## Translatable Widget Props + +Not every widget property needs translation. Here's the map of translatable text: + +| Widget Type | Translatable Props | +|---|---| +| `html-widget` | `content` | +| `markdown-text` | `content` | +| `tabs-widget` | `tabs[].label` | +| `layout-container-widget` | `nestedPageName` | +| `photo-card` | — *(title/description from `pictures` table)* | +| `gallery-widget` | — | +| `file-browser` | — | +| Container (settings) | `settings.title` | + +The shared function `iterateWidgets()` from `@polymech/shared` can walk the full content tree to extract translatable strings per widget. + +--- + +## Content Versioning Flow + +```mermaid +flowchart TD + A["Page Editor"] -->|save| B["pages.content — working draft"] + B -->|publish / snapshot| C["content_versions — immutable v1, v2, ..."] + C -->|translate via DeepL / manual| D["content_translations — per version + lang"] +``` + +### Version Lifecycle + +1. **Author saves** → `pages.content` updated (working state, no version bump) +2. **Author publishes** → new row in `content_versions` (hash of content JSON, version++) +3. **Translation triggered** → walks content tree, translates per widget, stores `widget_translations` + assembles a full `content_translations` row +4. **Source changes** → next publish creates version N+1, all `content_translations` for version N get `is_stale = true` +5. **Retranslation** → only re-translates widgets whose `source_text` changed (compare hashes) + +--- + +## Serving Translated Pages + +When a page is requested with `?lang=fr`: + +``` +1. Look up content_translations WHERE entity_id = ? AND target_lang = 'fr' AND status = 'published' +2. If found → serve translated_content directly (no extra processing) +3. If not found → serve source content (fallback) +4. If is_stale = true → serve but add X-Translation-Stale: true header +``` + +Add `lang` to the enrichment / cache key in `getPagesState()` or create a parallel `getTranslatedPagesState()`. + +--- + +## Integration with Existing i18n + +The existing `i18n_translations` table continues to serve as **the text-level translation cache** (src → dst lookup). The new tables add **structural awareness** on top: + +``` +i18n_translations → text cache (DeepL results, any text) +widget_translations → maps widget+prop → translation pair +content_translations → full translated content snapshot +content_versions → immutable source snapshots +``` + +`translateTextServer()` (from `i18n-deepl.ts`) remains the engine. The new translation logic calls it per widget prop, then assembles results. + +--- + +## External Translation Services (Crowdin, Phrase, Lokalise) + +### The Problem + +Our page content is **deeply nested JSON** (`RootLayoutData` → pages → containers → widgets → props). External TMS platforms don't understand this structure — they work with **flat key→value files** in standard formats. + +We need an **extract/inject pipeline** that converts between our JSON tree and industry-standard formats. + +### Exchange Format Strategy + +| Format | Best For | Crowdin | Phrase | Lokalise | +|---|---|---|---|---| +| **XLIFF 2.0** | Industry standard, rich metadata, tool support | ✅ | ✅ | ✅ | +| **Flat JSON** | Simple key→value, easy to diff | ✅ | ✅ | ✅ | +| **ICU MessageFormat** | Plurals, gender, variables | ✅ | ✅ | ✅ | + +**Recommended primary format: XLIFF 2.0** — it carries source + target in one file, supports notes/context for translators, and every TMS speaks it natively. + +**Secondary: Flat JSON** — for scripting, quick diffs, and lightweight integrations. + +### Key Design — Stable Translation Keys + +Every translatable string gets a **stable key** derived from its position in the content tree: + +``` +page..widget.. +``` + +Examples: +``` +page.a1b2c3.widget.w-markdown-1.content +page.a1b2c3.widget.w-tabs-1.tabs.0.label +page.a1b2c3.widget.w-tabs-1.tabs.1.label +page.a1b2c3.container.c-hero.settings.title +page.a1b2c3.meta.title ← page title itself +``` + +These keys are **widget-ID-based**, not position-based. If a widget moves within the page, its key stays the same. If a widget is deleted, its key disappears from the next export. + +### XLIFF Export Example + +```xml + + + + + + Page title + 255 + + + Kunststoff-Recycling Übersicht + Plastic Recycling Overview + + + + + Markdown text widget — supports markdown formatting + markdown-text + + + ## Einleitung\n\nDiese Seite beschreibt... + + + + + + Tab label + 50 + + + Übersicht + + + + + +``` + +### Flat JSON Export Example + +```json +{ + "_meta": { + "entity_type": "page", + "entity_id": "a1b2c3", + "source_version": 3, + "source_lang": "de", + "exported_at": "2026-02-17T10:00:00Z" + }, + "page.a1b2c3.meta.title": "Kunststoff-Recycling Übersicht", + "page.a1b2c3.widget.w-md-1.content": "## Einleitung\n\nDiese Seite beschreibt...", + "page.a1b2c3.widget.w-tabs-1.tabs.0.label": "Übersicht", + "page.a1b2c3.widget.w-tabs-1.tabs.1.label": "Details", + "page.a1b2c3.container.c-hero.settings.title": "Willkommen" +} +``` + +### Extract → Export → Translate → Import → Inject Pipeline + +```mermaid +flowchart LR + subgraph OUR_SYSTEM["Our System"] + CV["content_versions v3"] -->|"1 EXTRACT\niterateWidgets"| KV["Flat key-value map"] + KV -->|"2 EXPORT\nserialize to XLIFF or JSON"| FILE_OUT[".xliff / .json file"] + FILE_IN["Translated .xliff / .json"] -->|"3 IMPORT\nparse to key-value map"| KV_TR["Translated key-value map"] + KV_TR -->|"4 INJECT\nwalk tree, replace strings"| CT["content_translations"] + KV_TR -->|"4 INJECT"| WT["widget_translations"] + end + + subgraph TMS["External TMS"] + CROWDIN["Crowdin / Phrase / Lokalise"] + HUMAN["Human translators + MT review"] + CROWDIN --> HUMAN + HUMAN --> CROWDIN + end + + FILE_OUT --> CROWDIN + CROWDIN --> FILE_IN +``` + +### How Human Translation Fits the Status Flow + +```mermaid +flowchart TD + A["Machine translate via DeepL"] --> B["status = machine"] + B --> C["Export to TMS"] + C --> D["Human review and edit"] + D --> E["Import back"] + E --> F["status = reviewed"] + F --> G["Editor approves"] + G --> H["status = published"] +``` + +1. **Machine pre-fill**: DeepL translates all strings → stored with `status = 'machine'` +2. **Export to TMS**: export the machine-translated file (with source + target pre-filled) so human translators only need to **review and fix**, not translate from scratch +3. **Import from TMS**: translated file comes back → `status = 'reviewed'` +4. **Publish**: editor approves → `status = 'published'`, `content_translations` assembled + +### API Additions for TMS Interop + +| Method | Endpoint | Description | +|---|---|---| +| `GET` | `/api/pages/:id/export/:lang?format=xliff` | Export translatable strings as XLIFF or JSON | +| `POST` | `/api/pages/:id/import/:lang` | Import translated XLIFF or JSON file | +| `GET` | `/api/pages/:id/export/:lang?format=json` | Export as flat JSON | +| `POST` | `/api/i18n/webhook/crowdin` | Crowdin webhook for auto-import on completion | + +### Crowdin-Specific Integration Notes + +- **Source files**: upload the flat JSON export as a "source file" per page +- **File naming**: `page-{slug}-v{version}.json` — Crowdin tracks versions by filename +- **Branches**: use Crowdin branches to match `content_versions` — branch = version +- **Webhooks**: Crowdin fires `file.translated` / `file.approved` → our webhook imports +- **In-Context**: Crowdin's in-context editing can work via our `?lang=pseudo` mode that renders keys instead of text + +### Glossary Sync + +The existing `i18n_glossaries` / `i18n_glossary_terms` tables can be: +- **Exported** as TBX (TermBase eXchange) or Crowdin-compatible CSV +- **Synced bidirectionally**: terms added in Crowdin → imported to our DB → pushed to DeepL glossary + +This keeps DeepL machine translations and human translations using the **same terminology**. + +--- + +## API Surface (Proposed) + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/api/pages/:id/publish` | Snapshot current content → `content_versions` | +| `GET` | `/api/pages/:id/versions` | List versions for a page | +| `GET` | `/api/pages/:id/versions/:v` | Get specific version snapshot | +| `POST` | `/api/pages/:id/translate` | Translate page to target lang(s) | +| `GET` | `/api/pages/:id/translations` | List available translations | +| `GET` | `/api/pages/:id/translations/:lang` | Get translated content for lang | +| `PATCH` | `/api/pages/:id/translations/:lang/widgets/:wid` | Update single widget translation | +| `POST` | `/api/content/:type/:id/publish` | Generic publish for any entity type | +| `POST` | `/api/content/:type/:id/translate` | Generic translate for any entity type | + +--- + +## Open Questions / Decisions Needed + +1. **Publish-on-save vs explicit publish?** + Do we auto-version on every save, or require an explicit "Publish" action? + *Recommend:* explicit publish to avoid version spam. + +2. **Widget-level table — now or later?** + `widget_translations` adds complexity. We could start with page-level only (`content_translations`) and add widget-level later. + *Recommend:* start with both — widget-level is needed for partial retranslation. + +3. **Store full content in `content_versions` or just the hash?** + Storing full JSON enables rollback but costs storage. + *Recommend:* store it — pages are small (< 100 KB each), rollback is high value. + +4. **Which entity types beyond pages?** + Posts? Collections? Categories? + *Recommend:* start with pages only, the schema is generic enough to extend. + +5. **UI for translation management?** + A side-by-side translation editor? Or just an "auto-translate" button? + This doc covers the backend schema only — UI TBD. + +--- + +## Migration Priority + +| Phase | Scope | Tables | +|---|---|---| +| **Phase 1** | Content versioning for pages | `content_versions` | +| **Phase 2** | Page-level translations | `content_translations` | +| **Phase 3** | Widget-level translations | `widget_translations` | +| **Phase 4** | Extend to posts / collections | Same tables, new `entity_type` values | diff --git a/packages/ui/docs/page-commands.md b/packages/ui/docs/page-commands.md new file mode 100644 index 00000000..e8e07182 --- /dev/null +++ b/packages/ui/docs/page-commands.md @@ -0,0 +1,87 @@ +# Page Commands & Undo/Redo System Proposal + +## Overview +To support robust Undo/Redo functionality for the User Page Builder, we propose implementing the **Command Pattern**. Every modification to the page layout (add, remove, move, resize, update settings) will be encapsulated as a `Command` object. + +## Command Interface + +```typescript +interface Command { + id: string; + type: string; + timestamp: number; + execute(): Promise; + undo(): Promise; +} +``` + +## Command Stack +We will maintain two stacks in the `LayoutContext` or a new `HistoryContext`: +- `past: Command[]` +- `future: Command[]` + +## Proposed Commands + +### 1. AddWidgetCommand +- **Execute**: Adds a widget to a specific container/index. +- **Undo**: Removes the widget with the specific ID. + +### 2. RemoveWidgetCommand +- **Execute**: Removes a widget. Store the widget's state (props, ID, location) before removal. +- **Undo**: Restores the widget to its original container/index with preserved props. + +### 3. MoveWidgetCommand +- **Execute**: Moves widget from (Container A, Index X) to (Container B, Index Y). +- **Undo**: Moves widget back to (Container A, Index X). + +### 4. UpdateWidgetSettingsCommand +- **Execute**: Updates `widget.props` with new values. Store `previousProps`. +- **Undo**: Reverts `widget.props` to `previousProps`. + +### 5. AddContainerCommand / RemoveContainerCommand +- Similar logic to widgets but for layout containers. + +### 6. ResizeContainerCommand +- **Execute**: Updates container column sizes. +- **Undo**: Reverts to previous column sizes. + +## Implementation Strategy + +1. **Refactor `useLayout`**: Move direct state mutations into specific command classes or factory functions. +2. **Action Dispatcher**: Create a `dispatch(action)` function that: + - Creates the appropriate Command. + - Executes `command.execute()`. + - Pushes command to `past` stack. + - Clears `future` stack. +3. **Hotkeys**: Bind `Ctrl+Z` (Undo) and `Ctrl+Y / Ctrl+Shift+Z` (Redo). + +## Storage Boundaries & Persistence + +### 1. In-Memory Store (Primary) +- **Scope**: Current Browser Tab / Session. +- **Implementation**: React State or `useReducer` within `LayoutContext`. +- **Behavior**: fast, synchronous updates. Cleared on page reload or navigation. + +### 2. Browser Storage (localStorage) +- **Role**: Crash Recovery & Session Continuity. +- **Strategy**: + - Persist the `currentLayout` state to `localStorage` on every change (debounced). + - **Proposed**: Persist the `past` and `future` command stacks to `localStorage` as well. + - **Constraint**: All `Command` objects must be strictly JSON-serializable (no function references). + - **Key**: `page_editor_history_${pageId}`. +- **Benefit**: Users can refresh the page and still Undo their last action. + +### 3. Server State (Database) +- **Role**: Permanent Storage & Collaboration Source of Truth. +- **Interaction**: + - "Save" commits the current state to Supabase. + - **History Clearance**: Typically, saving *does not* clear the Undo history (allowing "Undo Save"), but navigating away does. + - **Dirty State**: If `past.length > lastSavedIndex`, the UI shows "Unsaved Changes". + +### 4. Boundary Enforcement +- **Serialization**: Commands must store *copies* of data (e.g., `previousProps`), not references to live objects. +- **Isolation**: Undo operations must not trigger side effects (like API calls) other than updating the local layout state, unless explicitly designed (e.g., re-uploading a deleted image is complex; usually we just restore the *reference* to the image URL). + +## Edge Cases +- **Multi-user editing**: Simple command history assumes single-player mode. Implementation complexity increases significantly with real-time collaboration (requiring OT or CRDTs). For now, we assume last-write-wins or locking. +- **Failed operations**: If `execute()` fails, the command stack should not update. diff --git a/packages/ui/docs/page-edit.md b/packages/ui/docs/page-edit.md new file mode 100644 index 00000000..bd6162b9 --- /dev/null +++ b/packages/ui/docs/page-edit.md @@ -0,0 +1,84 @@ +# UserPageEdit Refactoring Plan + +The `UserPageEdit.tsx` component has grown too large (~900 lines) and handles too many responsibilities. This plan outlines the steps to decompose it into manageable, single-purpose components and custom hooks, leveraging the existing Action System for cleaner communication. + +## 1. Goal +Split `UserPageEdit.tsx` to improve maintainability, verify separation of concerns, and fully utilize the `src/actions/` system to decouple UI components (like the Ribbon) from the logic. + +## 2. Proposed Architecture + +### 2.1. Directories +`src/pages/editor/` + - `components/` + - `hooks/` + - `UserPageEdit.tsx` (The main entry point, simplified) + +### 2.2. Custom Hooks & Logic +Move state and logic into `src/pages/editor/hooks/`. Crucially, we will use `useActions` to register capabilities. + +1. **`usePageEditorState.ts`** + - Manages UI state: `isSidebarCollapsed`, `showHierarchy`, `showTypeFields`, `selectedWidgetId`. + - **Action Registration**: Registers actions like `View/ToggleSidebar`, `View/ToggleHierarchy`, `View/ToggleTypeFields`. + +2. **`usePageTemplates.ts`** + - Manages template state. + - **Action Registration**: Registers `File/LoadTemplate`, `File/SaveTemplate`. + +3. **`useEmailActions.ts`** + - Manages email state. + - **Action Registration**: Registers `Email/SendTest`, `Email/TogglePreview`. + +4. **`useEditorActions.ts` (Core Logic)** + - Wraps `useLayout` and `useLayouts` context methods. + - **Action Registration**: + - `Edit/Undo` + - `Edit/Redo` + - `File/Save` + - `File/ImportLayout` + - `File/ExportLayout` + - `Edit/AddContainer` + - `Edit/AddWidget` + - `Edit/DeletePage` + +### 2.3. Components (UI Extraction) +Move UI sections into `src/pages/editor/components/`: + +1. **`EditorSidebar.tsx`** + - Subscribes to UI state via context or props (managed by `UserPageEdit`). + - Handlers can trigger Actions. + - Renders `HierarchyTree`. + +2. **`EditorMainArea.tsx`** + - The central workspace. + - Renders `GenericCanvas`. + +3. **`EditorRightPanel.tsx`** + - The properties panel. + +4. **`PageRibbonBar.tsx` (Refactor)** + - **Change**: Instead of accepting 30+ props, it will use `useActions()` to retrieve registered actions (`Save`, `Undo`, `Redo`, `ToggleVisibility`, etc.) and bind them to buttons. + - Props will be minimized to just `page` (for context) and layout specific data. + +5. **`EditorDialogs.tsx`** + - A container component that renders all global dialogs (Email, Settings, Templates) based on state. + +## 3. Implementation Steps + +1. **Setup Directory**: Create `src/pages/editor/` structure. +2. **RefactorHooks**: + - Implement `useEditorActions` to register core actions. + - Implement `usePageEditorState` for UI toggles. +3. **Refactor `PageRibbonBar`**: + - Update it to use `useActions().getActionsByGroup('History')` etc. + - Remove prop drilling for `onUndo`, `onRedo`, `onSave`. +4. **Extract Components**: + - Move JSX to `EditorSidebar`, `EditorMainArea`, `EditorRightPanel`. +5. **Reassemble `UserPageEdit`**: + - Initialize hooks. + - Render `ActionProvider` (if not at top level) or ensure hooks run inside it. + - Pass minimal props to children. + +## 4. Verification +- **Ribbon Functionality**: Verify buttons (Undo, Redo, Save) are active/disabled correctly via Action state. +- **Shortcuts**: Verify Ctrl+Z/Y work via the Action registry. +- **Layout**: Verify UI allows adding widgets/containers. diff --git a/packages/ui/docs/tabs.md b/packages/ui/docs/tabs.md new file mode 100644 index 00000000..042ccc67 --- /dev/null +++ b/packages/ui/docs/tabs.md @@ -0,0 +1,95 @@ +# Tabs Widget Proposal + +## Overview +A new **Tabs Widget** that allows organizing content into multiple switchable tabs. Each tab will contain its own nested layout, capable of holding multiple widgets, similar to the `LayoutContainerWidget`. + +## Data Structure +The widget will maintain a list of tabs, where each tab holds a reference to a unique "Virtual Page ID" essentially acting as a container for other widgets. + +```typescript +interface TabDefinition { + id: string; // Unique identifier for the tab + label: string; // Display text + layoutId: string; // The 'pageId' used for the nested GenericCanvas + icon?: string; // Optional icon name +} + +interface TabsWidgetProps { + tabs: TabDefinition[]; + activeTabId: string; + orientation: 'horizontal' | 'vertical'; + tabBarPosition: 'top' | 'bottom' | 'left' | 'right'; + className?: string; // Container classes + tabBarClassName?: string; // Tab bar specific classes + contentClassName?: string; // Content area classes +} +``` + +## Implementation Strategy + +### 1. Widget Registration (`registerWidgets.ts`) +Register a new `TabsWidget` with a custom configuration schema. + +- **Tabs Management**: A dedicated property editor (array of objects) to add/remove/reorder tabs and rename them. +- **Orientation/Position**: Selectors for tab bar placement. +- **Styling**: Tailwind CSS class pickers for container, tab bar, and content area. + +### 2. Component Structure (`TabsWidget.tsx`) +The component will render: +1. **Tab Bar**: A list of buttons/tabs. +2. **Content Area**: renders a `GenericCanvas` for the currently active tab. + +```tsx +// simplified pseudo-code +const TabsWidget = ({ tabs, activeTabId, ...props }) => { + const [currentTabId, setCurrentTabId] = useState(activeTabId || tabs[0]?.id); + const currentTab = tabs.find(t => t.id === currentTabId); + + return ( +
+ +
+ {currentTab && ( + + )} +
+
+ ); +} +``` + +### 3. Widget Properties Interface (`WidgetPropertiesForm.tsx`) +We need a new field type: `'array-objects'` or specifically `'tabs-editor'` to manage the list of tabs. +- **Add Tab**: Generates a new `layoutId` (e.g., `tabs--`) and adds to the list. +- **Edit Tab**: Change label/icon. +- **Remove Tab**: Removes from list (and ideally cleans up the layout, though we might leave orphans for safety initially). +- **Reorder**: Drag-and-drop reordering. + +## UX & Styling +- **Tailwind Support**: Fully transparent styling via props. +- **Default Styles**: Clean, modern tab look (border-b active state). +- **Edit Mode**: When in edit mode, the `GenericCanvas` inside the active tab should allow dropping widgets just like the main canvas. + +## Nested Layout Handling +By reusing `GenericCanvas`, we automatically get: +- Drag & Drop support. +- Widget resizing within the tab. +- Persistence (provided the backend `layouts` table or `page_layouts` supports these virtual IDs). + +## Dependencies +- `GenericCanvas`: For rendering the nested content. +- `dnd-kit` (or similar): For reordering tabs in the property panel. +- `lucide-react`: For tab icons. + +## Roadmap +1. **Scaffold**: Create `TabsWidget.tsx` and register it. +2. **Properties**: Update `WidgetPropertiesForm` to support managing a list of tabs. +3. **Integration**: Verify nested drag-and-drop works correctly within `GenericCanvas`. diff --git a/packages/ui/docs/type-system.md b/packages/ui/docs/type-system.md new file mode 100644 index 00000000..775feff2 --- /dev/null +++ b/packages/ui/docs/type-system.md @@ -0,0 +1,210 @@ +# Type System Documentation + +This document outlines the architecture of the Polymech Type System, covering the database schema, server-side logic, and the client-side visual builder. + +## Overview + +The Type System allows for dynamic definition of data structures, enums, flags, and aliases. It is built on top of Supabase (PostgreSQL) and supports a visual builder interface for creating and managing these types. The system is designed to be recursive, allowing structures to contain fields that refer to other types. + +## Database Schema + +The core of the system is the `types` table, which stores the definitions. Relationships between types (inheritance, composition) are modeled via auxiliary tables. + +```mermaid +erDiagram + types { + uuid id PK + string name + enum kind "primitive, enum, flags, structure, alias, field" + uuid parent_type_id FK "Inheritance / Alias Target" + string description + jsonb json_schema "JSON Schema representation" + uuid owner_id + enum visibility "public, private, custom" + jsonb meta "UI Schema, arbitrary metadata" + jsonb settings + } + + type_structure_fields { + uuid id PK + uuid structure_type_id FK "The parent Structure" + uuid field_type_id FK "The Type of the field" + string field_name + boolean required + jsonb default_value + int order + } + + type_enum_values { + uuid id PK + uuid type_id FK + string value + string label + int order + } + + type_flag_values { + uuid id PK + uuid type_id FK + string name + int bit + } + + type_casts { + uuid from_type_id FK + uuid to_type_id FK + enum cast_kind "implicit, explicit, lossy" + } + + types ||--|{ type_structure_fields : "defines fields" + types ||--|{ type_structure_fields : "used as field type" + types ||--|{ type_enum_values : "has values" + types ||--|{ type_flag_values : "has flags" + types ||--|{ type_casts : "can cast to/from" + types ||--|| types : "parent_type_id" +``` + +### Source Reference +- **Supabase Types Definition**: [src/integrations/supabase/types.ts](../src/integrations/supabase/types.ts) + +## Architecture & Data Flow + +The system uses a synchronized Client-Server model. The client (React) uses a visual builder to construct a schema, which is then translated into API calls to the server (Hono/Node.js). The server handles the persistence logic, ensuring referential integrity. + +### Data Flow Pattern + +```mermaid +sequenceDiagram + participant UI as TypesPlayground (React) + participant Builder as TypeBuilder + participant ClientDB as Client DB Layer (db.ts) + participant API as Server API + participant ServerLogic as Server Logic (db-types.ts) + participant DB as Supabase (PostgreSQL) + + Note over UI, Builder: User creates a new Structure + UI->>Builder: Opens Builder Mode + Builder->>Builder: User drags "String" to Canvas + Builder->>Builder: User names field "title" + + Builder->>UI: onSave(BuilderOutput) + + rect rgb(240, 248, 255) + Note right of UI: Structure Creation Logic + + loop For each field element + UI->>ClientDB: createType({ kind: 'field', ... }) + ClientDB->>API: POST /api/types + API->>ServerLogic: createTypeServer() + ServerLogic->>DB: INSERT into types (kind='field') + DB-->>ServerLogic: New Field Type ID + ServerLogic-->>API: Type Object + API-->>ClientDB: 200 OK + ClientDB-->>UI: New Field Type + end + + UI->>ClientDB: createType({ kind: 'structure', structure_fields: [...] }) + Note right of UI: Links previously created Field Types + ClientDB->>API: POST /api/types + end + + API->>ServerLogic: createTypeServer() + ServerLogic->>DB: INSERT into types (kind='structure') + ServerLogic->>DB: INSERT into type_structure_fields + DB-->>ServerLogic: Structure ID + ServerLogic-->>API: Structure Object + API-->>ClientDB: 200 OK + ClientDB-->>UI: Structure Type + UI->>UI: Refreshes List +``` + +### Source Reference +- **Client DB Layer**: [src/components/types/db.ts](../src/components/types/db.ts) +- **Server DB Logic**: [server/src/products/serving/db/db-types.ts](../server/src/products/serving/db/db-types.ts) + +## Client-Side: Type Builder + +The `TypeBuilder` component provides a drag-and-drop interface for composing types. It manages an internal state of `measurements` which are then converted to the `TypeDefinition` format expected by the DB. + +### Builder Class Diagram + +```mermaid +classDiagram + class TypesPlayground { + +TypeDefinition[] types + +string selectedTypeId + +handleCreateNew() + +handleEditVisual() + +handleBuilderSave(BuilderOutput) + } + + class TypeBuilder { + +BuilderMode mode + +BuilderElement[] elements + +string typeName + +handleDragEnd() + +deleteElement() + } + + class BuilderElement { + +string id + +string type + +string name + +string refId + +UISchema uiSchema + } + + class TypeDefinition { + +string id + +string name + +string kind + +StructureField[] structure_fields + } + + TypesPlayground *-- TypeBuilder : manages + TypeBuilder *-- BuilderElement : contains + TypesPlayground ..> TypeDefinition : maps to/from +``` + +### Key Components +- **TypesPlayground**: Main orchestrator. Handles loading types, selection, and the save logic which coordinates the atomic creation of fields and structures. + - Source: [src/components/types/TypesPlayground.tsx](../src/components/types/TypesPlayground.tsx) +- **TypeBuilder**: The Dnd-Kit powered canvas. It transforms the user's visual arrangement into a structured `BuilderOutput`. + - Source: [src/components/types/TypeBuilder.tsx](../src/components/types/TypeBuilder.tsx) + +## Server-Side Logic + +The server enforces the schema structure. When creating a `structure`, it handles the insertion of the main type record and the associated `structure_fields` records. + +### Logic Highlights +1. **Creation**: + - Inserts the `types` record. + - If `structure_fields` are present, performs batch inserts into `type_structure_fields`. + - Handles `enum_values` and `flag_values` similarly. +2. **Updates**: + - Updates attributes of the main `types` record. + - **Replacement Strategy**: For `structure_fields`, it often performs a `DELETE` (of all existing link records for that structure) followed by an `INSERT` of the new set to ensure order and composition are exactly as requested. + - **Orphan Cleanup**: Accepts `fieldsToDelete` array to clean up `field` types that were removed from the structure. + +### Source Reference +### Source Reference +- [server/src/products/serving/db/db-types.ts](../server/src/products/serving/db/db-types.ts) + +## Performance & Caching + +To ensure low-latency access to type definitions (which are read frequently but updated rarely), a robust server-side caching strategy is implemented. + +### Strategy +- **Load-All-On-Demand**: The system treats the entire set of Types as a single cohesive unit. When any type is requested, if the cache is cold, *all* types, fields, enums, casts, and flags are loaded from the database in parallel. +- **In-Memory Cache**: The Enriched Types map is stored in the application's memory (`AppCache`) with a TTL (default 5 minutes). +- **Write-Through / Invaludation**: + - **Mutations**: Any Create, Update, or Delete operation triggers a `flushTypeCache()` call. + - **Invalidation**: This clears the `types` cache key. + - **Reload**: The mutation response typically triggers a fresh fetch, which immediately repopulates the cache with the latest data. + +### Event Propagation +Invalidation events are broadcasted to connected clients via Server-Sent Events (SSE). + +1. **Server**: `AppCache.invalidate('types')` emits an `app-update` event. +2. **Stream**: The event stream manager forwards this to all active client connections. +3. **Client**: The `StreamContext` receives the `cache` event (type: `types`) and triggers a React Query invalidation, ensuring the UI reflects the schema change instantly. diff --git a/packages/ui/docs/types.md b/packages/ui/docs/types.md index 98227410..163a1a0f 100644 --- a/packages/ui/docs/types.md +++ b/packages/ui/docs/types.md @@ -137,5 +137,12 @@ The system is seeded with the following primitive types (immutable system types) - `reference` - `alias` ---- -*Generated by Antigravity* +- `alias` + +## Caching & Consistency + +The Type System uses a high-performance in-memory caching layer (`AppCache`) to ensure fast read access. + +- **Read Operations**: Responses for `/api/types` are cached with a 5-minute TTL. +- **Write Operations**: Creating, Updating, or Deleting types immediately **invalidates** the cache. +- **Real-time Updates**: Clients connected to the SSE stream (`/api/stream`) receive `app-update` events (type: `types`), allowing UIs to refresh schemas instantly without manual reloading. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3d7e9839..e6bd26df 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -94,6 +94,9 @@ const AppWrapper = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/packages/ui/src/components/CategoryTreeView.tsx b/packages/ui/src/components/CategoryTreeView.tsx new file mode 100644 index 00000000..9b2874d3 --- /dev/null +++ b/packages/ui/src/components/CategoryTreeView.tsx @@ -0,0 +1,125 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchCategories, type Category } from "@/modules/categories/client-categories"; +import { useNavigate, useParams } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { useState, useCallback } from "react"; +import { FolderTree, ChevronRight, ChevronDown, Home, Loader2 } from "lucide-react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; + +interface CategoryTreeViewProps { + /** Called after a category is selected (useful for closing mobile sheet) */ + onNavigate?: () => void; +} + +const CategoryTreeView = ({ onNavigate }: CategoryTreeViewProps) => { + const { slug: activeSlug } = useParams<{ slug?: string }>(); + const navigate = useNavigate(); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const { data: categories = [], isLoading } = useQuery({ + queryKey: ['categories'], + queryFn: () => fetchCategories({ includeChildren: true }), + staleTime: 1000 * 60 * 5, + }); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const handleSelect = useCallback((slug: string | null) => { + if (slug) { + navigate(`/categories/${slug}`); + } else { + navigate('/'); + } + onNavigate?.(); + }, [navigate, onNavigate]); + + const renderNode = (cat: Category, depth: number = 0) => { + const hasChildren = cat.children && cat.children.length > 0; + const isActive = cat.slug === activeSlug; + const isExpanded = expandedIds.has(cat.id); + + return ( +
+ hasChildren && toggleExpand(cat.id)}> +
+ {/* Expand/collapse chevron */} + {hasChildren ? ( + + + + ) : ( + /* spacer */ + )} + + {/* Category label — clickable to navigate */} + +
+ + {hasChildren && ( + + {cat.children!.map(rel => renderNode(rel.child, depth + 1))} + + )} +
+
+ ); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + + ); +}; + +export default CategoryTreeView; diff --git a/packages/ui/src/components/ImageLightbox.tsx b/packages/ui/src/components/ImageLightbox.tsx index 2f7fd114..491db252 100644 --- a/packages/ui/src/components/ImageLightbox.tsx +++ b/packages/ui/src/components/ImageLightbox.tsx @@ -115,6 +115,17 @@ export default function ImageLightbox({ const isSwipingRef = useRef(false); const isPanningRef = useRef(false); + // Detect mobile for disabling zoom + const [isMobile, setIsMobile] = useState(() => + typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches + ); + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + // Use external prompt if provided (controlled), otherwise internal state (uncontrolled) const lightboxPrompt = externalPrompt !== undefined ? externalPrompt : internalPrompt; const setLightboxPrompt = (value: string) => { @@ -249,6 +260,37 @@ export default function ImageLightbox({ if (!isOpen) return null; + const responsiveImageEl = ( + setLightboxLoaded(true)} + onClick={(e: React.MouseEvent) => { + // Only toggle controls if we haven't been panning + if (!isPanningRef.current && showPrompt) { + e.stopPropagation(); + setShowPromptField(!showPromptField); + } + }} + onTouchStart={(e: React.TouchEvent) => { + const touch = e.touches[0]; + handleSwipeStart(touch.clientX, touch.clientY); + }} + onTouchEnd={(e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + handleSwipeEnd(touch.clientX, touch.clientY); + }} + /> + ); return (
- setScale(e.state.scale)} - > - setScale(e.state.scale)} > - setLightboxLoaded(true)} - onClick={(e: React.MouseEvent) => { - // Only toggle controls if we haven't been panning - if (!isPanningRef.current && showPrompt) { - e.stopPropagation(); - setShowPromptField(!showPromptField); - } - }} - /> - - + + {responsiveImageEl} + + + )}
{!lightboxLoaded && (
diff --git a/packages/ui/src/components/ListLayout.tsx b/packages/ui/src/components/ListLayout.tsx index f78c4689..f14a4cb6 100644 --- a/packages/ui/src/components/ListLayout.tsx +++ b/packages/ui/src/components/ListLayout.tsx @@ -160,6 +160,11 @@ export const ListLayout = ({ } }, [feedPosts, isMobile, selectedId]); + // Reset selection when category changes + useEffect(() => { + setSelectedId(null); + }, [categorySlugs?.join(',')]); + if (loading && feedPosts.length === 0) { return
Loading...
; } @@ -193,10 +198,17 @@ export const ListLayout = ({
{/* Right: Detail */} -
+
{selectedId ? ( (() => { const selectedPost = feedPosts.find((p: any) => p.id === selectedId); + if (!selectedPost) { + return ( +
+ Select an item to view details +
+ ); + } const postAny = selectedPost as any; // Check for slug in various locations depending on data structure diff --git a/packages/ui/src/components/MediaCard.tsx b/packages/ui/src/components/MediaCard.tsx index be53ed1d..46a93bc5 100644 --- a/packages/ui/src/components/MediaCard.tsx +++ b/packages/ui/src/components/MediaCard.tsx @@ -7,6 +7,7 @@ import React from 'react'; import PhotoCard from './PhotoCard'; import VideoCard from '@/components/VideoCard'; import PageCard from '@/modules/pages/PageCard'; +import type { CardPreset } from '@/modules/pages/PageCard'; import { normalizeMediaType, MEDIA_TYPES, type MediaType } from '@/lib/mediaRegistry'; interface MediaCardProps { @@ -35,6 +36,7 @@ interface MediaCardProps { variant?: 'grid' | 'feed'; apiUrl?: string; versionCount?: number; + preset?: CardPreset; } const MediaCard: React.FC = ({ @@ -62,7 +64,8 @@ const MediaCard: React.FC = ({ job, variant = 'grid', apiUrl, - versionCount + versionCount, + preset }) => { const normalizedType = normalizeMediaType(type); // Render based on type @@ -127,6 +130,7 @@ const MediaCard: React.FC = ({ responsive={responsive} variant={variant} apiUrl={apiUrl} + preset={preset} /> ); } diff --git a/packages/ui/src/components/PhotoGrid.tsx b/packages/ui/src/components/PhotoGrid.tsx index 44fab1b2..f1c8eca8 100644 --- a/packages/ui/src/components/PhotoGrid.tsx +++ b/packages/ui/src/components/PhotoGrid.tsx @@ -14,6 +14,7 @@ import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry"; import { UploadCloud, Maximize, FolderTree } from "lucide-react"; import { toast } from "sonner"; import type { MediaType } from "@/types"; +import type { CardPreset } from "@/modules/pages/PageCard"; export interface MediaItemType { id: string; @@ -52,8 +53,11 @@ interface MediaGridProps { supabaseClient?: any; apiUrl?: string; categorySlugs?: string[]; + preset?: CardPreset; } +const DEFAULT_PRESET: CardPreset = { showTitle: true, showDescription: true }; + const MediaGrid = ({ customPictures, customLoading, @@ -65,7 +69,8 @@ const MediaGrid = ({ sortBy = 'latest', supabaseClient, apiUrl, - categorySlugs + categorySlugs, + preset = DEFAULT_PRESET }: MediaGridProps) => { const { user } = useAuth(); const navigate = useNavigate(); @@ -511,7 +516,7 @@ const MediaGrid = ({ {section.title} )} -
+
{section.items.map((item, index) => { const itemType = normalizeMediaType(item.type); const isVideo = isVideoType(itemType); @@ -544,6 +549,7 @@ const MediaGrid = ({ job={item.job} responsive={item.responsive} apiUrl={apiUrl} + preset={preset} />
@@ -578,6 +584,7 @@ const MediaGrid = ({ job={item.job} responsive={item.responsive} apiUrl={apiUrl} + preset={preset} /> ); })} diff --git a/packages/ui/src/components/widgets/ImagePickerDialog.tsx b/packages/ui/src/components/widgets/ImagePickerDialog.tsx index a63dd095..2cd51df8 100644 --- a/packages/ui/src/components/widgets/ImagePickerDialog.tsx +++ b/packages/ui/src/components/widgets/ImagePickerDialog.tsx @@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { T, translate } from '@/i18n'; -import { Search, Image as ImageIcon, Check, Tag, FolderOpen, X } from 'lucide-react'; +import { Search, Image as ImageIcon, Tag, FolderOpen, X } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; import MediaCard from '@/components/MediaCard'; import { MediaType } from '@/lib/mediaRegistry'; diff --git a/packages/ui/src/hooks/useResponsiveImage.ts b/packages/ui/src/hooks/useResponsiveImage.ts index 82c4f6cc..0ed99b21 100644 --- a/packages/ui/src/hooks/useResponsiveImage.ts +++ b/packages/ui/src/hooks/useResponsiveImage.ts @@ -72,8 +72,10 @@ export const useResponsiveImage = ({ if (!requestCache.has(cacheKey)) { const requestPromise = (async () => { const serverUrl = apiUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL || 'http://192.168.1.11:3333'; + // Resolve relative URLs to absolute so the server-side API can fetch them + const resolvedSrc = src.startsWith('/') ? `${window.location.origin}${src}` : src; const params = new URLSearchParams({ - url: src, + url: resolvedSrc, sizes: JSON.stringify(responsiveSizes), formats: JSON.stringify(formats), }); diff --git a/packages/ui/src/modules/pages/FileBrowserWidget.tsx b/packages/ui/src/modules/pages/FileBrowserWidget.tsx index 96425c56..5a4b56f2 100644 --- a/packages/ui/src/modules/pages/FileBrowserWidget.tsx +++ b/packages/ui/src/modules/pages/FileBrowserWidget.tsx @@ -8,6 +8,7 @@ import { } from 'lucide-react'; import type { FileBrowserWidgetProps } from '@polymech/shared'; import { useAuth } from '@/hooks/useAuth'; +import ResponsiveImage from '@/components/ResponsiveImage'; // ── Types ──────────────────────────────────────────────────────── @@ -119,10 +120,10 @@ function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] { function ThumbPreview({ node, mount, height = 64, tokenParam = '' }: { node: INode; mount: string; height?: number; tokenParam?: string }) { const cat = getMimeCategory(node); - const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`; + const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path.replace(/^\/+/, '')}`; const fileUrl = tokenParam ? `${baseUrl}?${tokenParam}` : baseUrl; if (cat === 'image') { - return {node.name}; + return ; } if (cat === 'video') { return ( @@ -638,8 +639,8 @@ const FileBrowserWidget: React.FC
{getMimeCategory(selectedFile) === 'image' && ( - {selectedFile.name} + )} {getMimeCategory(selectedFile) === 'video' && (
); diff --git a/packages/ui/src/modules/pages/UserPage.tsx b/packages/ui/src/modules/pages/UserPage.tsx index 59aff1d1..709517d6 100644 --- a/packages/ui/src/modules/pages/UserPage.tsx +++ b/packages/ui/src/modules/pages/UserPage.tsx @@ -318,6 +318,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false, userProfile={userProfile} isOwner={isOwner} isEditMode={isEditMode} + embedded={embedded} userId={userId || ''} // Fallback if undefined, though it should be defined if loaded orgSlug={orgSlug} onPageUpdate={handlePageUpdate} @@ -359,7 +360,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
{page.parent && ( View parent page diff --git a/packages/ui/src/modules/pages/editor/UserPageDetails.tsx b/packages/ui/src/modules/pages/editor/UserPageDetails.tsx index b8397e6c..36cc1848 100644 --- a/packages/ui/src/modules/pages/editor/UserPageDetails.tsx +++ b/packages/ui/src/modules/pages/editor/UserPageDetails.tsx @@ -27,6 +27,7 @@ interface UserPageDetailsProps { userProfile: UserProfile | null; isOwner: boolean; isEditMode: boolean; + embedded?: boolean; userId: string; orgSlug?: string; onPageUpdate: (updatedPage: Page) => void; @@ -42,6 +43,7 @@ export const UserPageDetails: React.FC = ({ userProfile, isOwner, isEditMode, + embedded = false, userId, orgSlug, onPageUpdate, @@ -264,6 +266,7 @@ export const UserPageDetails: React.FC = ({ page={page} isOwner={isOwner} isEditMode={isEditMode} + embedded={embedded} onToggleEditMode={() => { onToggleEditMode(); if (isEditMode) onWidgetRename(null); diff --git a/packages/ui/src/modules/pages/editor/UserPageEdit.tsx b/packages/ui/src/modules/pages/editor/UserPageEdit.tsx index e8faf599..d828e011 100644 --- a/packages/ui/src/modules/pages/editor/UserPageEdit.tsx +++ b/packages/ui/src/modules/pages/editor/UserPageEdit.tsx @@ -588,7 +588,7 @@ const UserPageEditInner = ({
{page.parent && ( View parent page diff --git a/packages/ui/src/modules/posts/client-pictures.ts b/packages/ui/src/modules/posts/client-pictures.ts index 9a567b91..9a3b38c2 100644 --- a/packages/ui/src/modules/posts/client-pictures.ts +++ b/packages/ui/src/modules/posts/client-pictures.ts @@ -1,7 +1,6 @@ import { supabase as defaultSupabase, supabase } from "@/integrations/supabase/client"; -import { z } from "zod"; -import { UserProfile, PostMediaItem } from "@/pages/Post/types"; -import { MediaType, MediaItem } from "@/types"; +import { PostMediaItem } from "@/pages/Post/types"; +import { MediaItem } from "@/types"; import { SupabaseClient } from "@supabase/supabase-js"; import { fetchWithDeduplication } from "@/lib/db"; import { FetchMediaOptions } from "@/utils/mediaUtils"; diff --git a/packages/ui/src/modules/search/client-search.ts b/packages/ui/src/modules/search/client-search.ts new file mode 100644 index 00000000..4f4b2830 --- /dev/null +++ b/packages/ui/src/modules/search/client-search.ts @@ -0,0 +1,31 @@ +const SERVER_API_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || ''; + +export interface SearchOptions { + q: string; + limit?: number; + type?: 'all' | 'pages' | 'posts' | 'pictures'; + sizes?: string; + formats?: string; +} + +/** + * Search content using server FTS. + * Returns enriched FeedPost-shaped objects (with cover, responsive, author, etc.) + */ +export const searchContent = async (options: SearchOptions): Promise => { + const params = new URLSearchParams(); + params.append('q', options.q); + if (options.limit) params.append('limit', String(options.limit)); + if (options.type && options.type !== 'all') params.append('type', options.type); + if (options.sizes) params.append('sizes', options.sizes); + if (options.formats) params.append('formats', options.formats); + + const url = `${SERVER_API_URL}/api/search?${params.toString()}`; + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Search request failed: ${res.status}`); + } + + return res.json(); +}; diff --git a/packages/ui/src/pages/Index.tsx b/packages/ui/src/pages/Index.tsx index 020e3ca9..b48be2d8 100644 --- a/packages/ui/src/pages/Index.tsx +++ b/packages/ui/src/pages/Index.tsx @@ -3,16 +3,32 @@ import GalleryLarge from "@/components/GalleryLarge"; import MobileFeed from "@/components/feed/MobileFeed"; import { useMediaRefresh } from "@/contexts/MediaRefreshContext"; import { useIsMobile } from "@/hooks/use-mobile"; -import { useState, useEffect } from "react"; -import { useParams } from "react-router-dom"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useParams, useLocation, useNavigate } from "react-router-dom"; import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree } from "lucide-react"; import { ListLayout } from "@/components/ListLayout"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import type { FeedSortOption } from "@/hooks/useFeedData"; import { SEO } from "@/components/SEO"; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; +import CategoryTreeView from "@/components/CategoryTreeView"; + +const SIDEBAR_KEY = 'categorySidebarSize'; +const DEFAULT_SIDEBAR = 15; const Index = () => { const { slug } = useParams<{ slug?: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + + // Derive mode from URL pathname + const pathname = location.pathname; + const isTopRoute = pathname === '/top'; + const isCategoriesRoute = pathname === '/categories' || pathname.startsWith('/categories/'); + + const sortBy: FeedSortOption = isTopRoute ? 'top' : 'latest'; + const showCategories = isCategoriesRoute; const categorySlugs = slug ? [slug] : undefined; const { refreshKey } = useMediaRefresh(); @@ -24,12 +40,45 @@ const Index = () => { useEffect(() => { localStorage.setItem('feedViewMode', viewMode); }, [viewMode]); - const [sortBy, setSortBy] = useState('latest'); + + // Mobile sheet state (auto-open when navigating to /categories on mobile) + const [sheetOpen, setSheetOpen] = useState(false); + const closeSheet = useCallback(() => setSheetOpen(false), []); + + useEffect(() => { + if (isCategoriesRoute && isMobile) setSheetOpen(true); + }, [isCategoriesRoute, isMobile]); + + // Persist sidebar size + const [sidebarSize, setSidebarSize] = useState(() => { + const stored = localStorage.getItem(SIDEBAR_KEY); + return stored ? Number(stored) : DEFAULT_SIDEBAR; + }); + const handleSidebarResize = useCallback((size: number) => { + setSidebarSize(size); + localStorage.setItem(SIDEBAR_KEY, String(size)); + }, []); + + // Navigation helpers — toggles navigate to URLs + const handleSortChange = useCallback((value: string) => { + if (!value) return; + if (value === 'latest') navigate('/latest'); + else if (value === 'top') navigate('/top'); + else if (value === 'categories') navigate('/categories'); + }, [navigate]); + + const handleCategoriesToggle = useCallback(() => { + if (isCategoriesRoute) { + // Un-toggle: go back to /latest or /top based on nothing + navigate('/latest'); + } else { + navigate('/categories'); + } + }, [isCategoriesRoute, navigate]); const renderCategoryBreadcrumb = () => { if (!slug) return null; - // Fallback if no category path found return (
@@ -40,87 +89,150 @@ const Index = () => { ); }; + // Determine which sort toggle is active + const activeSortValue = isTopRoute ? 'top' : isCategoriesRoute ? '' : 'latest'; + + // --- Shared sort + categories toggle bar --- + const renderSortBar = (size?: 'sm') => ( + <> + + + + Latest + + + + Top + + + + + + Categories + + + {renderCategoryBreadcrumb()} + + ); + + // --- Feed views --- + const renderFeed = () => { + if (isMobile) { + return viewMode === 'list' ? ( + + ) : ( + window.location.href = `/post/${id}`} /> + ); + } + + if (viewMode === 'grid') { + return ; + } else if (viewMode === 'large') { + return ; + } + return ; + }; + return (
+ + {/* Mobile: Sheet for category navigation */} + {isMobile && ( + + + + Categories + Browse categories + +
+ +
+
+
+ )} +
-
-
- {/* Mobile Feed View */} - {isMobile ? ( -
-
-
- v && setSortBy(v as FeedSortOption)}> - - - Latest - - - - Top - - - {renderCategoryBreadcrumb()} -
- - v && setViewMode(v as any)}> - - - - - - - -
- {viewMode === 'list' ? ( - - ) : ( - window.location.href = `/post/${id}`} /> - )} + {isMobile ? ( + /* ---- Mobile layout ---- */ +
+
+
+ {renderSortBar('sm')}
- ) : ( - /* Desktop/Tablet Grid View */ -
-
-
- v && setSortBy(v as FeedSortOption)}> - - - Latest - - - - Top - - - {renderCategoryBreadcrumb()} -
- - v && setViewMode(v as any)}> - - - - - - - - - - -
- - {viewMode === 'grid' ? ( - - ) : viewMode === 'large' ? ( - - ) : ( - - )} -
- )} + v && setViewMode(v as any)}> + + + + + + + +
+ {renderFeed()}
-
+ ) : showCategories ? ( + /* ---- Desktop with category sidebar ---- */ +
+
+
+ {renderSortBar()} +
+ v && setViewMode(v as any)}> + + + + + + + + + + +
+ + + +
+
+ Categories +
+ +
+
+ + + {renderFeed()} + +
+
+ ) : ( + /* ---- Desktop without sidebar ---- */ +
+
+
+ {renderSortBar()} +
+ v && setViewMode(v as any)}> + + + + + + + + + + +
+ {renderFeed()} +
+ )}
); diff --git a/packages/ui/src/pages/Post.tsx b/packages/ui/src/pages/Post.tsx index eb2af378..36c8c970 100644 --- a/packages/ui/src/pages/Post.tsx +++ b/packages/ui/src/pages/Post.tsx @@ -897,10 +897,10 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) => const containerClassName = embedded ? `flex flex-col bg-background h-[inherit] ${className || ''}` - : "bg-background flex flex-col h-[inherit]"; + : "bg-background flex flex-col"; if (!embedded && !className) { - className = "h-full" + className = "h-[calc(100vh-4rem)]" } /* console.log('containerClassName', containerClassName); @@ -918,7 +918,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) => type={isVideo ? 'video.other' : 'article'} /> )} -
+
{viewMode === 'thumbs' ? ( diff --git a/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx b/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx index abab3dc8..21734064 100644 --- a/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx +++ b/packages/ui/src/pages/Post/renderers/CompactRenderer.tsx @@ -53,6 +53,7 @@ export const CompactRenderer: React.FC = (props) => { mediaItem={mediaItem} authorProfile={authorProfile!} isOwner={!!isOwner} + embedded={props.embedded} onViewModeChange={onViewModeChange!} onExportMarkdown={onExportMarkdown!} onSaveChanges={onSaveChanges!} @@ -129,6 +130,7 @@ export const CompactRenderer: React.FC = (props) => { mediaItem={mediaItem} authorProfile={authorProfile!} isOwner={!!isOwner} + embedded={props.embedded} onViewModeChange={onViewModeChange!} onExportMarkdown={onExportMarkdown!} onSaveChanges={onSaveChanges!} diff --git a/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx b/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx index d0473192..2f8e543d 100644 --- a/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx +++ b/packages/ui/src/pages/Post/renderers/components/CompactPostHeader.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Link } from "react-router-dom"; -import { LayoutGrid, Edit3, MoreVertical, Trash2, Save, X, Grid, FolderTree } from 'lucide-react'; +import { LayoutGrid, Edit3, MoreVertical, Trash2, Save, X, Grid, FolderTree, ExternalLink } from 'lucide-react'; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; @@ -20,6 +20,7 @@ interface CompactPostHeaderProps { mediaItem: PostMediaItem; authorProfile: UserProfile; isOwner: boolean; + embedded?: boolean; onViewModeChange: (mode: 'thumbs' | 'compact') => void; onExportMarkdown: (type: 'hugo' | 'obsidian' | 'raw') => void; onSaveChanges: () => void; @@ -40,6 +41,7 @@ export const CompactPostHeader: React.FC = ({ mediaItem, authorProfile, isOwner, + embedded = false, onViewModeChange, onExportMarkdown, onSaveChanges, @@ -136,6 +138,19 @@ export const CompactPostHeader: React.FC = ({
+ {/* Open Standalone - break out of embedded view */} + {embedded && post?.id && ( + + )} + { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { user } = useAuth(); const { setNavigationData } = usePostNavigation(); - const [pictures, setPictures] = useState([]); - const [users, setUsers] = useState([]); - const [collections, setCollections] = useState([]); - const [tags, setTags] = useState([]); + + const [feedPosts, setFeedPosts] = useState([]); const [userLikes, setUserLikes] = useState>(new Set()); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('all'); @@ -62,9 +30,7 @@ const SearchResults = () => { useEffect(() => { if (query.trim()) { performSearch(); - if (user) { - fetchUserLikes(); - } + if (user) loadLikes(); } else { setLoading(false); } @@ -73,409 +39,134 @@ const SearchResults = () => { const performSearch = async () => { try { setLoading(true); - - // Search pictures by title and description - const { data: picturesData, error: picturesError } = await supabase - .from('pictures') - .select('*') - .or(`title.ilike.%${query}%,description.ilike.%${query}%`) - .order('created_at', { ascending: false }) - .limit(20); + const data = await searchContent({ q: query, limit: 50 }); + setFeedPosts(data); - if (picturesError) throw picturesError; - - // Filter out pictures that are only in private collections - const publicPictures = await filterPrivateCollectionPictures(picturesData || []); - - // Get comment counts for pictures - const picturesWithCommentCounts = await Promise.all( - publicPictures.map(async (picture) => { - const { count } = await supabase - .from('comments') - .select('*', { count: 'exact', head: true }) - .eq('picture_id', picture.id); - - return { ...picture, comments: [{ count: count || 0 }] }; - }) - ); - - setPictures(picturesWithCommentCounts); - - // Set up navigation data for lightbox - setNavigationData({ - posts: picturesWithCommentCounts.map(p => ({ - id: p.id, - title: p.title, - image_url: p.image_url, - user_id: p.user_id - })), - currentIndex: 0, - source: 'search', - sourceId: query - }); - - // Search users by username and display name - const { data: usersData, error: usersError } = await supabase - .from('profiles') - .select('user_id, username, display_name, bio, avatar_url') - .or(`username.ilike.%${query}%,display_name.ilike.%${query}%`) - .order('created_at', { ascending: false }) - .limit(20); - - if (usersError) throw usersError; - setUsers(usersData || []); - - // Search collections by name and description - const { data: collectionsData, error: collectionsError } = await supabase - .from('collections') - .select('*') - .eq('is_public', true) - .or(`name.ilike.%${query}%,description.ilike.%${query}%`) - .order('created_at', { ascending: false }) - .limit(20); - - if (collectionsError) throw collectionsError; - - // Get picture counts for collections - const collectionsWithCounts = await Promise.all( - (collectionsData || []).map(async (collection) => { - const { count } = await supabase - .from('collection_pictures') - .select('*', { count: 'exact', head: true }) - .eq('collection_id', collection.id); - - return { ...collection, picture_count: count || 0 }; - }) - ); - - setCollections(collectionsWithCounts); - - // Search for tags (if query looks like a hashtag or could be a tag) - const normalizedQuery = normalizeTag(query.replace('#', '')); - if (normalizedQuery) { - const { data: tagPictures } = await supabase - .from('pictures') - .select('tags') - .not('tags', 'is', null); - - const foundTags = new Set(); - tagPictures?.forEach(picture => { - picture.tags?.forEach(tag => { - if (tag.toLowerCase().includes(normalizedQuery.toLowerCase())) { - foundTags.add(tag); - } - }); + // Set up lightbox navigation for picture results + const mediaItems = mapFeedPostsToMediaItems(data); + if (mediaItems.length > 0) { + setNavigationData({ + posts: mediaItems.map((m: any) => ({ + id: m.id, + title: m.title || '', + image_url: m.image_url || '', + user_id: m.user_id || '' + })), + currentIndex: 0, + source: 'search', + sourceId: query }); - setTags(Array.from(foundTags).slice(0, 10)); - } else { - setTags([]); } - } catch (error) { - console.error('Error performing search:', error); + console.error('Search failed:', error); toast.error('Failed to perform search'); } finally { setLoading(false); } }; - const fetchUserLikes = async () => { + const loadLikes = async () => { if (!user) return; - try { - const { data, error } = await supabase - .from('likes') - .select('picture_id') - .eq('user_id', user.id); - - if (error) throw error; - setUserLikes(new Set(data.map(like => like.picture_id))); + const { pictureLikes } = await fetchUserMediaLikes(user.id); + setUserLikes(pictureLikes); } catch (error) { - console.error('Error fetching user likes:', error); + console.error('Error fetching likes:', error); } }; - const handleImageClick = (pictureId: string) => { - navigate(`/post/${pictureId}`); + const handleMediaClick = (item: any) => { + const source = item._searchSource || item.type; + if (source === 'page' || item.type === 'page-intern') { + const slug = item.meta?.slug || item.slug; + const owner = item.user_id || item.owner; + if (slug && owner) { + navigate(`/user/${owner}/pages/${slug}`); + } + } else { + navigate(`/post/${item.id}`); + } }; - const getTotalResults = () => { - return pictures.length + users.length + collections.length + tags.length; + // Filter by source tag + const filterBySource = (source: string) => feedPosts.filter((p: any) => p._searchSource === source); + + const pages = filterBySource('page'); + const posts = filterBySource('post'); + const pictures = filterBySource('picture'); + + // Map to MediaItemType for rendering + const getMediaItems = (feedPostsSubset: any[]) => mapFeedPostsToMediaItems(feedPostsSubset); + + const renderMediaGrid = (items: any[]) => { + const mediaItems = getMediaItems(items); + if (mediaItems.length === 0) return null; + + return ( +
+ {mediaItems.map((item: any, index: number) => ( + handleMediaClick(items[index])} + created_at={item.created_at} + variant="grid" + versionCount={item.versionCount} + /> + ))} +
+ ); + }; + + const renderSection = ( + title: string, + icon: React.ReactNode, + items: any[], + tabKey: SearchTab, + previewCount: number + ) => { + if (items.length === 0) return null; + const preview = items.slice(0, previewCount); + return ( +
+
+

{icon} {title} ({items.length})

+ {items.length > previewCount && ( + + )} +
+ {renderMediaGrid(preview)} +
+ ); }; const getTabContent = () => { switch (activeTab) { + case 'pages': + return renderMediaGrid(pages); + case 'posts': + return renderMediaGrid(posts); case 'pictures': - return ( -
- {pictures.map((picture) => ( - - ))} -
- ); - - case 'users': - return ( -
- {users.map((user) => ( - -
-
-
- {user.avatar_url ? ( - {user.display_name - ) : ( - - )} -
-
-

- {user.display_name || `User ${user.user_id.slice(0, 8)}`} -

- {user.username && ( -

@{user.username}

- )} - {user.bio && ( -

- {user.bio} -

- )} -
-
-
- - ))} -
- ); - - case 'collections': - return ( -
- {collections.map((collection) => ( - -
-
- -
-

- {collection.name} -

- {collection.description && ( -

- {collection.description} -

- )} -
- {collection.picture_count} photos - {new Date(collection.created_at).toLocaleDateString()} -
-
- - ))} -
- ); - - case 'tags': - return ( -
- {tags.map((tag) => ( - -
- - - {tag} - -
- - ))} -
- ); - - default: // 'all' + return renderMediaGrid(pictures); + default: return (
- {/* Pictures Section */} - {pictures.length > 0 && ( -
-
-

Pictures ({pictures.length})

- -
-
- {pictures.slice(0, 4).map((picture) => ( - - ))} -
-
- )} - - {/* Users Section */} - {users.length > 0 && ( -
-
-

Users ({users.length})

- -
-
- {users.slice(0, 3).map((user) => ( - -
-
-
- {user.avatar_url ? ( - {user.display_name - ) : ( - - )} -
-
-

- {user.display_name || `User ${user.user_id.slice(0, 8)}`} -

- {user.username && ( -

@{user.username}

- )} -
-
-
- - ))} -
-
- )} - - {/* Collections Section */} - {collections.length > 0 && ( -
-
-

Collections ({collections.length})

- -
-
- {collections.slice(0, 3).map((collection) => ( - -
-
- -
-

- {collection.name} -

-
- {collection.picture_count} photos -
-
- - ))} -
-
- )} - - {/* Tags Section */} - {tags.length > 0 && ( -
-
-

Tags ({tags.length})

- -
-
- {tags.slice(0, 6).map((tag) => ( - -
- - - {tag} - -
- - ))} -
-
- )} + {renderSection('Pages', , pages, 'pages', 4)} + {renderSection('Posts', , posts, 'posts', 4)} + {renderSection('Pictures', , pictures, 'pictures', 8)}
); } @@ -496,25 +187,20 @@ const SearchResults = () => {

Enter a search term

-

- Search for pictures, users, collections, or hashtags -

+

Search for pages, posts, and pictures

); } - const totalResults = getTotalResults(); - return (
{/* Header */}
@@ -523,79 +209,49 @@ const SearchResults = () => {
-

- Search results for "{query}" -

+

Search results for "{query}"

- {totalResults} {totalResults === 1 ? 'result' : 'results'} found + {feedPosts.length} {feedPosts.length === 1 ? 'result' : 'results'} found

{/* Tabs */} - {totalResults > 0 && ( + {feedPosts.length > 0 && (
+ {pages.length > 0 && ( + + )} + {posts.length > 0 && ( + + )} {pictures.length > 0 && ( - )} - {users.length > 0 && ( - - )} - {collections.length > 0 && ( - - )} - {tags.length > 0 && ( - )}
@@ -603,13 +259,11 @@ const SearchResults = () => { )} {/* Content */} - {totalResults === 0 ? ( + {feedPosts.length === 0 ? (

No results found

-

- Try searching with different keywords -

+

Try searching with different keywords

) : ( getTabContent()