search 1/2

This commit is contained in:
lovebird 2026-02-19 09:24:43 +01:00
parent f99cf78e6b
commit 41fbfd2369
27 changed files with 1647 additions and 708 deletions

414
packages/ui/docs/i18n.md Normal file
View File

@ -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.<page_id>.widget.<widget_id>.<prop_path>
```
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
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="2.0" srcLang="de" trgLang="en">
<file id="page-a1b2c3" original="page/a1b2c3">
<unit id="page.a1b2c3.meta.title">
<notes>
<note category="context">Page title</note>
<note category="max-length">255</note>
</notes>
<segment>
<source>Kunststoff-Recycling Übersicht</source>
<target>Plastic Recycling Overview</target>
</segment>
</unit>
<unit id="page.a1b2c3.widget.w-md-1.content">
<notes>
<note category="context">Markdown text widget — supports markdown formatting</note>
<note category="widget-type">markdown-text</note>
</notes>
<segment>
<source>## Einleitung\n\nDiese Seite beschreibt...</source>
<target/>
</segment>
</unit>
<unit id="page.a1b2c3.widget.w-tabs-1.tabs.0.label">
<notes>
<note category="context">Tab label</note>
<note category="max-length">50</note>
</notes>
<segment>
<source>Übersicht</source>
<target/>
</segment>
</unit>
</file>
</xliff>
```
### 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 |

View File

@ -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<void>;
undo(): Promise<void>;
}
```
## 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.

View File

@ -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.

95
packages/ui/docs/tabs.md Normal file
View File

@ -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 (
<div className="flex flex-col ...">
<TabBar
tabs={tabs}
activeId={currentTabId}
onSelect={setCurrentTabId}
/>
<div className="flex-1 relative">
{currentTab && (
<GenericCanvas
key={currentTab.layoutId} // Key ensures remount/proper context switch
pageId={currentTab.layoutId}
isEditMode={isEditMode}
/>
)}
</div>
</div>
);
}
```
### 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-<widgetId>-<timestamp>`) 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`.

View File

@ -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.

View File

@ -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.

View File

@ -94,6 +94,9 @@ const AppWrapper = () => {
<Route path="/collections/new" element={<NewCollection />} />
<Route path="/collections/:userId/:slug" element={<Collections />} />
<Route path="/tags/:tag" element={<TagPage />} />
<Route path="/latest" element={<Index />} />
<Route path="/top" element={<Index />} />
<Route path="/categories" element={<Index />} />
<Route path="/categories/:slug" element={<Index />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/wizard" element={<Wizard />} />

View File

@ -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<Set<string>>(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 (
<div key={cat.id}>
<Collapsible open={isExpanded} onOpenChange={() => hasChildren && toggleExpand(cat.id)}>
<div
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors",
"hover:bg-muted/60",
isActive && "bg-primary/10 text-primary font-medium",
)}
style={{ paddingLeft: `${8 + depth * 14}px` }}
>
{/* Expand/collapse chevron */}
{hasChildren ? (
<CollapsibleTrigger asChild>
<button
className="shrink-0 p-0.5 rounded hover:bg-muted"
onClick={(e) => e.stopPropagation()}
>
{isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
}
</button>
</CollapsibleTrigger>
) : (
<span className="w-[18px] shrink-0" /> /* spacer */
)}
{/* Category label — clickable to navigate */}
<button
className="flex items-center gap-1.5 truncate text-left flex-1 min-w-0"
onClick={() => handleSelect(cat.slug)}
>
<FolderTree className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{cat.name}</span>
</button>
</div>
{hasChildren && (
<CollapsibleContent>
{cat.children!.map(rel => renderNode(rel.child, depth + 1))}
</CollapsibleContent>
)}
</Collapsible>
</div>
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<nav className="flex flex-col gap-0.5 py-2 text-sm select-none">
{/* "All" root item */}
<button
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors text-left",
"hover:bg-muted/60",
!activeSlug && "bg-primary/10 text-primary font-medium",
)}
onClick={() => handleSelect(null)}
>
<Home className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span>All</span>
</button>
{/* Category tree */}
{categories.map(cat => renderNode(cat))}
</nav>
);
};
export default CategoryTreeView;

View File

@ -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 = (
<ResponsiveImage
src={generatedImageUrl || imageUrl}
alt={imageTitle}
sizes={`${Math.ceil(scale * 100)}vw`}
responsiveSizes={[640, 1024]}
imgClassName={isMobile
? "max-w-[100dvw] max-h-[100dvh] object-contain pointer-events-auto"
: "max-w-[90vw] max-h-[90vh] object-contain cursor-grab active:cursor-grabbing pointer-events-auto"
}
className="w-full h-full flex items-center justify-center"
loading="eager"
draggable={false}
onLoad={() => setLightboxLoaded(true)}
onClick={(e: React.MouseEvent) => {
// Only toggle controls if we haven't been panning
if (!isPanningRef.current && showPrompt) {
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 (
<div
@ -280,44 +322,29 @@ export default function ImageLightbox({
>
<div className="relative w-full h-full flex items-center justify-center">
<div className="relative w-full h-full flex items-center justify-center">
<TransformWrapper
initialScale={1}
minScale={1}
maxScale={40}
centerOnInit={true}
centerZoomedOut={true}
limitToBounds={true}
{isMobile ? responsiveImageEl : (
<TransformWrapper
initialScale={1}
minScale={1}
maxScale={40}
centerOnInit={true}
centerZoomedOut={true}
limitToBounds={true}
alignmentAnimation={{ animationTime: 200, animationType: 'easeOut' }}
wheel={{ step: 1 }}
doubleClick={{ disabled: false, step: 0.7 }}
pinch={{ step: 20 }}
onTransformed={(e) => setScale(e.state.scale)}
>
<TransformComponent
wrapperClass="w-full h-full"
contentClass=""
alignmentAnimation={{ animationTime: 200, animationType: 'easeOut' }}
wheel={{ step: 1 }}
doubleClick={{ disabled: false, step: 0.7 }}
pinch={{ step: 20 }}
onTransformed={(e) => setScale(e.state.scale)}
>
<ResponsiveImage
src={generatedImageUrl || imageUrl}
alt={imageTitle}
sizes={`${Math.ceil(scale * 100)}vw`}
responsiveSizes={[640, 1024, 2048]}
imgClassName="max-w-[90vw] max-h-[90vh] object-contain cursor-grab active:cursor-grabbing pointer-events-auto"
className="w-full h-full flex items-center justify-center"
loading="eager"
draggable={false}
onLoad={() => setLightboxLoaded(true)}
onClick={(e: React.MouseEvent) => {
// Only toggle controls if we haven't been panning
if (!isPanningRef.current && showPrompt) {
e.stopPropagation();
setShowPromptField(!showPromptField);
}
}}
/>
</TransformComponent>
</TransformWrapper>
<TransformComponent
wrapperClass="w-full h-full"
contentClass=""
>
{responsiveImageEl}
</TransformComponent>
</TransformWrapper>
)}
</div>
{!lightboxLoaded && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">

View File

@ -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 <div className="p-8 text-center text-muted-foreground">Loading...</div>;
}
@ -193,10 +198,17 @@ export const ListLayout = ({
</div>
{/* Right: Detail */}
<div className="bg-background overflow-hidden relative flex flex-col h-[inherit]">
<div className="flex-1 min-w-0 bg-background overflow-hidden relative flex flex-col h-[inherit]">
{selectedId ? (
(() => {
const selectedPost = feedPosts.find((p: any) => p.id === selectedId);
if (!selectedPost) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
Select an item to view details
</div>
);
}
const postAny = selectedPost as any;
// Check for slug in various locations depending on data structure

View File

@ -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<MediaCardProps> = ({
@ -62,7 +64,8 @@ const MediaCard: React.FC<MediaCardProps> = ({
job,
variant = 'grid',
apiUrl,
versionCount
versionCount,
preset
}) => {
const normalizedType = normalizeMediaType(type);
// Render based on type
@ -127,6 +130,7 @@ const MediaCard: React.FC<MediaCardProps> = ({
responsive={responsive}
variant={variant}
apiUrl={apiUrl}
preset={preset}
/>
);
}

View File

@ -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}
</h2>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4">
{section.items.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const isVideo = isVideoType(itemType);
@ -544,6 +549,7 @@ const MediaGrid = ({
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
preset={preset}
/>
<div className="absolute top-2 right-2 flex items-center justify-center w-8 h-8 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<Maximize className="w-4 h-4 text-white" />
@ -578,6 +584,7 @@ const MediaGrid = ({
job={item.job}
responsive={item.responsive}
apiUrl={apiUrl}
preset={preset}
/>
);
})}

View File

@ -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';

View File

@ -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),
});

View File

@ -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 <img src={fileUrl} alt={node.name} loading="lazy" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />;
return <ResponsiveImage src={fileUrl} alt={node.name} loading="lazy" responsiveSizes={[128, 256]} className="" imgClassName="" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />;
}
if (cat === 'video') {
return (
@ -638,8 +639,8 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }>
</div>
{getMimeCategory(selectedFile) === 'image' && (
<img src={getFileUrl(selectedFile)} alt={selectedFile.name}
style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
<ResponsiveImage src={getFileUrl(selectedFile)} alt={selectedFile.name}
responsiveSizes={[200, 400]} imgClassName="" style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
)}
{getMimeCategory(selectedFile) === 'video' && (
<video key={selectedFile.path} src={getFileUrl(selectedFile)} controls muted preload="metadata"
@ -716,10 +717,13 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetProps & { variables?: any }>
style={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'default', borderRadius: 4, background: '#000' }}
/>
) : (
<img
<ResponsiveImage
onClick={(e) => e.stopPropagation()}
src={getFileUrl(lightboxNode)}
alt={lightboxNode.name}
loading="eager"
responsiveSizes={[640, 1280, 1920]}
imgClassName=""
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', cursor: 'default', borderRadius: 4 }}
/>
)}

View File

@ -4,7 +4,7 @@ import { T, translate } from "@/i18n";
import { cn } from "@/lib/utils";
import { Database } from '@/integrations/supabase/types';
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate, ShoppingCart } from "lucide-react";
import { Eye, EyeOff, Edit3, Trash2, Share2, Link as LinkIcon, FileText, Download, FolderTree, FileJson, LayoutTemplate, ShoppingCart, ExternalLink } from "lucide-react";
import { useCartStore } from "@polymech/ecommerce";
import {
DropdownMenu,
@ -28,6 +28,7 @@ interface PageActionsProps {
page: Page;
isOwner: boolean;
isEditMode?: boolean;
embedded?: boolean;
onToggleEditMode?: () => void;
onPageUpdate: (updatedPage: Page) => void;
onDelete?: () => void;
@ -42,6 +43,7 @@ export const PageActions = ({
page,
isOwner,
isEditMode = false,
embedded = false,
onToggleEditMode,
onPageUpdate,
onDelete,
@ -350,6 +352,22 @@ draft: ${!page.visible}
return (
<div className={cn("flex items-center gap-2", className)}>
{/* Open Standalone - break out of embedded view */}
{embedded && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
window.open(`/user/${page.owner}/pages/${page.slug}`, '_blank');
}}
className="gap-2"
title={translate("Open in full page")}
>
<ExternalLink className="h-4 w-4" />
{showLabels && <span className="hidden md:inline"><T>Open</T></span>}
</Button>
)}
{/* Add to Cart - only for product pages with a price */}
{productPrice !== null && (
<Button

View File

@ -7,6 +7,13 @@ import MarkdownRenderer from "@/components/MarkdownRenderer";
import type { MediaRendererProps } from "@/lib/mediaRegistry";
import { getTikTokVideoId, getYouTubeVideoId } from "@/utils/mediaUtils";
/** Extensible preset configuration for card display */
export interface CardPreset {
showTitle?: boolean;
showDescription?: boolean;
// future: showAuthor, showPrice, showBadge, etc.
}
interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
variant?: 'grid' | 'feed';
responsive?: any;
@ -17,6 +24,7 @@ interface PageCardProps extends Omit<MediaRendererProps, 'created_at'> {
created_at?: string;
apiUrl?: string;
versionCount?: number;
preset?: CardPreset;
}
const PageCard: React.FC<PageCardProps> = ({
@ -40,7 +48,8 @@ const PageCard: React.FC<PageCardProps> = ({
showHeader = true,
overlayMode = 'hover',
apiUrl,
versionCount
versionCount,
preset
}) => {
// Determine image source
// If url is missing or empty, fallback to picsum
@ -163,7 +172,7 @@ const PageCard: React.FC<PageCardProps> = ({
// Grid Variant
return (
<div
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full ${isPlaying && tikTokId ? 'aspect-[9/16]' : 'aspect-square'}`}
className={`group relative overflow-hidden bg-card transition-all duration-300 cursor-pointer w-full rounded-lg ${preset?.showTitle ? '' : (isPlaying && tikTokId ? 'aspect-[9/16]' : 'aspect-square')}`}
onClick={(e) => {
if (isExternalVideo && !isPlaying) {
e.stopPropagation();
@ -173,94 +182,108 @@ const PageCard: React.FC<PageCardProps> = ({
}
}}
>
{isPlaying && isExternalVideo ? (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={tikTokId
? `https://www.tiktok.com/embed/v2/${tikTokId}`
: `https://www.youtube.com/embed/${ytId}?autoplay=1`
}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={title}
></iframe>
</div>
) : (
<>
<ResponsiveImage
src={displayImage}
alt={title}
className="w-full h-full"
imgClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
responsiveSizes={[320, 640, 1024]}
data={responsive}
apiUrl={apiUrl}
/>
<div className="absolute top-2 right-2 p-1.5 bg-black/60 rounded-md backdrop-blur-sm z-10">
{isExternalVideo ? (
<Play className="w-4 h-4 text-white fill-white" />
) : (
<FileText className="w-4 h-4 text-white" />
)}
<div className={`relative w-full overflow-hidden ${isPlaying && tikTokId ? 'aspect-[9/16]' : 'aspect-square'}`}>
{isPlaying && isExternalVideo ? (
<div className="w-full h-full bg-black flex justify-center">
<iframe
src={tikTokId
? `https://www.tiktok.com/embed/v2/${tikTokId}`
: `https://www.youtube.com/embed/${ytId}?autoplay=1`
}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={title}
></iframe>
</div>
) : (
<>
<ResponsiveImage
src={displayImage}
alt={title}
className="w-full h-full"
imgClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
responsiveSizes={[320, 640, 1024]}
data={responsive}
apiUrl={apiUrl}
/>
{isExternalVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-black/50 p-3 rounded-full backdrop-blur-sm group-hover:bg-black/70 transition-colors">
<Play className="w-8 h-8 text-white fill-white" />
</div>
<div className="absolute top-2 right-2 p-1.5 bg-black/60 rounded-md backdrop-blur-sm z-10">
{isExternalVideo ? (
<Play className="w-4 h-4 text-white fill-white" />
) : (
<FileText className="w-4 h-4 text-white" />
)}
</div>
)}
{showContent && (
<div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}>
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={handleLike}
className={`h-8 w-8 p-0 ${isLiked ? "text-red-500" : "text-white hover:text-red-500"}`}
>
<Heart className="h-4 w-4" fill={isLiked ? "currentColor" : "none"} />
</Button>
</div>
{isExternalVideo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-black/50 p-3 rounded-full backdrop-blur-sm group-hover:bg-black/70 transition-colors">
<Play className="w-8 h-8 text-white fill-white" />
</div>
</div>
)}
<h3 className="text-white font-medium mb-1 line-clamp-1">{title}</h3>
{description && (
<div className="text-white/80 text-sm mb-1 line-clamp-2">
<MarkdownRenderer content={description} className="prose-invert prose-white" />
{showContent && (
<div className={`hidden md:block absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent ${overlayMode === 'always' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity duration-300 pointer-events-none`}>
<div className="absolute bottom-0 left-0 right-0 p-4 pointer-events-auto">
<div className="flex items-center justify-between mb-2">
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author}
hoverStyle={true}
showDate={false}
/>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={handleLike}
className={`h-8 w-8 p-0 ${isLiked ? "text-red-500" : "text-white hover:text-red-500"}`}
>
<Heart className="h-4 w-4" fill={isLiked ? "currentColor" : "none"} />
</Button>
</div>
</div>
<h3 className="text-white font-medium mb-1 line-clamp-1">{title}</h3>
{description && (
<div className="text-white/80 text-sm mb-1 line-clamp-2">
<MarkdownRenderer content={description} className="prose-invert prose-white" />
</div>
)}
</div>
</div>
)}
{/* Mobile Footer used in Grid view on mobile */}
<div className="md:hidden absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<div className="flex items-center justify-between text-white">
<span className="text-xs font-medium truncate flex-1 mr-2">{title}</span>
{isExternalVideo ? (
<Play className="w-3 h-3 flex-shrink-0 fill-white" />
) : (
<FileText className="w-3 h-3 flex-shrink-0" />
)}
</div>
</div>
)}
</>
)}
</div>
{/* Mobile Footer used in Grid view on mobile */}
<div className="md:hidden absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
<div className="flex items-center justify-between text-white">
<span className="text-xs font-medium truncate flex-1 mr-2">{title}</span>
{isExternalVideo ? (
<Play className="w-3 h-3 flex-shrink-0 fill-white" />
) : (
<FileText className="w-3 h-3 flex-shrink-0" />
)}
</div>
</div>
</>
{/* Info bar below image (preset-driven) */}
{(preset?.showTitle || preset?.showDescription) && (title || description) && (
<div className="px-2.5 py-2 border-t">
{preset?.showTitle && title && (
<h3 className="text-sm font-medium truncate">{title}</h3>
)}
{preset?.showDescription && description && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{description}</p>
)}
</div>
)}
</div>
);

View File

@ -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,
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
to={`/user/${userId}/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>

View File

@ -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<UserPageDetailsProps> = ({
userProfile,
isOwner,
isEditMode,
embedded = false,
userId,
orgSlug,
onPageUpdate,
@ -264,6 +266,7 @@ export const UserPageDetails: React.FC<UserPageDetailsProps> = ({
page={page}
isOwner={isOwner}
isEditMode={isEditMode}
embedded={embedded}
onToggleEditMode={() => {
onToggleEditMode();
if (isEditMode) onWidgetRename(null);

View File

@ -588,7 +588,7 @@ const UserPageEditInner = ({
</div>
{page.parent && (
<Link
to={`/pages/${page.parent}`}
to={`/user/${page.owner}/pages/${page.parent}`}
className="text-primary hover:underline"
>
<T>View parent page</T>

View File

@ -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";

View File

@ -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<any[]> => {
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();
};

View File

@ -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<FeedSortOption>('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 (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<FolderTree className="h-4 w-4 shrink-0" />
@ -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') => (
<>
<ToggleGroup type="single" value={activeSortValue} onValueChange={handleSortChange}>
<ToggleGroupItem value="latest" aria-label="Latest Posts" size={size}>
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts" size={size}>
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" value={showCategories ? 'categories' : ''} onValueChange={handleCategoriesToggle}>
<ToggleGroupItem value="categories" aria-label="Show Categories" size={size}>
<FolderTree className="h-4 w-4 mr-2" />
Categories
</ToggleGroupItem>
</ToggleGroup>
{renderCategoryBreadcrumb()}
</>
);
// --- Feed views ---
const renderFeed = () => {
if (isMobile) {
return viewMode === 'list' ? (
<ListLayout key={refreshKey} sortBy={sortBy} navigationSource="home" categorySlugs={categorySlugs} />
) : (
<MobileFeed source="home" sortBy={sortBy} categorySlugs={categorySlugs} onNavigate={(id) => window.location.href = `/post/${id}`} />
);
}
if (viewMode === 'grid') {
return <PhotoGrid key={refreshKey} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} />;
} else if (viewMode === 'large') {
return <GalleryLarge key={refreshKey} sortBy={sortBy} categorySlugs={categorySlugs} />;
}
return <ListLayout key={refreshKey} sortBy={sortBy} categorySlugs={categorySlugs} />;
};
return (
<div className="bg-background">
<SEO title="PolyMech Home" />
{/* Mobile: Sheet for category navigation */}
{isMobile && (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="left" className="w-[280px] p-4">
<SheetHeader className="mb-2">
<SheetTitle className="text-sm">Categories</SheetTitle>
<SheetDescription className="sr-only">Browse categories</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto flex-1">
<CategoryTreeView onNavigate={closeSheet} />
</div>
</SheetContent>
</Sheet>
)}
<div className="md:py-2">
<div>
<div>
{/* Mobile Feed View */}
{isMobile ? (
<div className="md:hidden">
<div className="flex justify-between items-center px-4 mb-4 pt-2">
<div className="flex items-center gap-3">
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
<ToggleGroupItem value="latest" aria-label="Latest Posts" size="sm">
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts" size="sm">
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
{renderCategoryBreadcrumb()}
</div>
<ToggleGroup type="single" value={viewMode === 'list' ? 'list' : 'grid'} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Card View" size="sm">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List View" size="sm">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{viewMode === 'list' ? (
<ListLayout key={refreshKey} sortBy={sortBy} navigationSource="home" categorySlugs={categorySlugs} />
) : (
<MobileFeed source="home" sortBy={sortBy} categorySlugs={categorySlugs} onNavigate={(id) => window.location.href = `/post/${id}`} />
)}
{isMobile ? (
/* ---- Mobile layout ---- */
<div className="md:hidden">
<div className="flex justify-between items-center px-4 mb-4 pt-2">
<div className="flex items-center gap-2">
{renderSortBar('sm')}
</div>
) : (
/* Desktop/Tablet Grid View */
<div className="hidden md:block">
<div className="flex justify-between px-4 mb-4">
<div className="flex items-center gap-3">
<ToggleGroup type="single" value={sortBy} onValueChange={(v) => v && setSortBy(v as FeedSortOption)}>
<ToggleGroupItem value="latest" aria-label="Latest Posts">
<Clock className="h-4 w-4 mr-2" />
Latest
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts">
<TrendingUp className="h-4 w-4 mr-2" />
Top
</ToggleGroupItem>
</ToggleGroup>
{renderCategoryBreadcrumb()}
</div>
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Grid View">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="large" aria-label="Large View">
<GalleryVerticalEnd className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List View">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{viewMode === 'grid' ? (
<PhotoGrid key={refreshKey} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} />
) : viewMode === 'large' ? (
<GalleryLarge key={refreshKey} sortBy={sortBy} categorySlugs={categorySlugs} />
) : (
<ListLayout key={refreshKey} sortBy={sortBy} categorySlugs={categorySlugs} />
)}
</div>
)}
<ToggleGroup type="single" value={viewMode === 'list' ? 'list' : 'grid'} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Card View" size="sm">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List View" size="sm">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{renderFeed()}
</div>
</div>
) : showCategories ? (
/* ---- Desktop with category sidebar ---- */
<div className="hidden md:block">
<div className="flex justify-between px-4 mb-4">
<div className="flex items-center gap-3">
{renderSortBar()}
</div>
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Grid View">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="large" aria-label="Large View">
<GalleryVerticalEnd className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List View">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
<ResizablePanelGroup direction="horizontal" className="min-h-[calc(100vh-8rem)]">
<ResizablePanel
defaultSize={sidebarSize}
minSize={10}
maxSize={25}
onResize={handleSidebarResize}
>
<div className="h-full overflow-y-auto border-r px-2">
<div className="sticky top-0 bg-background/95 backdrop-blur-sm pb-1 pt-1 px-1 border-b mb-1">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Categories</span>
</div>
<CategoryTreeView />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={100 - sidebarSize}>
{renderFeed()}
</ResizablePanel>
</ResizablePanelGroup>
</div>
) : (
/* ---- Desktop without sidebar ---- */
<div className="hidden md:block">
<div className="flex justify-between px-4 mb-4">
<div className="flex items-center gap-3">
{renderSortBar()}
</div>
<ToggleGroup type="single" value={viewMode} onValueChange={(v) => v && setViewMode(v as any)}>
<ToggleGroupItem value="grid" aria-label="Grid View">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="large" aria-label="Large View">
<GalleryVerticalEnd className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List View">
<List className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{renderFeed()}
</div>
)}
</div>
</div>
);

View File

@ -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'}
/>
)}
<div className={embedded ? "w-full h-[inherit]" : "w-full max-w-[1600px] mx-auto"}>
<div className={embedded ? "w-full h-[inherit]" : "w-full max-w-[1600px] mx-auto h-full"}>
{viewMode === 'thumbs' ? (
<ThumbsRenderer {...rendererProps} />

View File

@ -53,6 +53,7 @@ export const CompactRenderer: React.FC<PostRendererProps> = (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<PostRendererProps> = (props) => {
mediaItem={mediaItem}
authorProfile={authorProfile!}
isOwner={!!isOwner}
embedded={props.embedded}
onViewModeChange={onViewModeChange!}
onExportMarkdown={onExportMarkdown!}
onSaveChanges={onSaveChanges!}

View File

@ -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<CompactPostHeaderProps> = ({
mediaItem,
authorProfile,
isOwner,
embedded = false,
onViewModeChange,
onExportMarkdown,
onSaveChanges,
@ -136,6 +138,19 @@ export const CompactPostHeader: React.FC<CompactPostHeaderProps> = ({
</Button>
</div>
{/* Open Standalone - break out of embedded view */}
{embedded && post?.id && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
onClick={() => window.open(`/post/${post.id}`, '_blank')}
title="Open in full page"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<ExportDropdown
post={isEditMode ? localPost || null : post || null}
mediaItems={isEditMode ? (localMediaItems as any) || [] : mediaItems}

View File

@ -1,58 +1,26 @@
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate, Link } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { usePostNavigation } from "@/contexts/PostNavigationContext";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Search, Hash, User, Image as ImageIcon, Calendar, Users } from "lucide-react";
import { ArrowLeft, Search, Image as ImageIcon, FileText, MessageSquare } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import PhotoCard from "@/components/PhotoCard";
import { normalizeTag } from "@/utils/tagUtils";
import { filterPrivateCollectionPictures } from "@/utils/collectionUtils";
import MediaCard from "@/components/MediaCard";
import { searchContent } from "@/modules/search/client-search";
import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts";
import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
import type { MediaType } from "@/types";
interface Picture {
id: string;
title: string;
description: string | null;
image_url: string;
likes_count: number;
created_at: string;
user_id: string;
tags: string[] | null;
comments: { count: number }[];
}
interface UserResult {
user_id: string;
username: string | null;
display_name: string | null;
bio: string | null;
avatar_url: string | null;
}
interface Collection {
id: string;
name: string;
description: string | null;
slug: string;
is_public: boolean;
user_id: string;
created_at: string;
picture_count?: number;
}
type SearchTab = 'all' | 'pictures' | 'users' | 'collections' | 'tags';
type SearchTab = 'all' | 'pages' | 'posts' | 'pictures';
const SearchResults = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const { setNavigationData } = usePostNavigation();
const [pictures, setPictures] = useState<Picture[]>([]);
const [users, setUsers] = useState<UserResult[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [feedPosts, setFeedPosts] = useState<any[]>([]);
const [userLikes, setUserLikes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<SearchTab>('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<string>();
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 (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{mediaItems.map((item: any, index: number) => (
<MediaCard
key={item.picture_id || item.id}
id={item.id}
pictureId={item.picture_id || item.id}
url={item.image_url || ''}
thumbnailUrl={item.thumbnail_url}
title={item.title || 'Untitled'}
author={item.author?.display_name || item.author?.username || ''}
authorId={item.user_id || ''}
likes={item.likes_count || 0}
comments={item.comments?.[0]?.count || 0}
isLiked={userLikes.has(item.picture_id || item.id)}
description={item.description}
type={item.type as MediaType}
responsive={item.responsive}
meta={item.meta}
onClick={() => handleMediaClick(items[index])}
created_at={item.created_at}
variant="grid"
versionCount={item.versionCount}
/>
))}
</div>
);
};
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 (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">{icon} {title} ({items.length})</h3>
{items.length > previewCount && (
<Button variant="ghost" size="sm" onClick={() => setActiveTab(tabKey)}>View all</Button>
)}
</div>
{renderMediaGrid(preview)}
</div>
);
};
const getTabContent = () => {
switch (activeTab) {
case 'pages':
return renderMediaGrid(pages);
case 'posts':
return renderMediaGrid(posts);
case 'pictures':
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{pictures.map((picture) => (
<PhotoCard
key={picture.id}
pictureId={picture.id}
image={picture.image_url}
title={picture.title}
author={`User ${picture.user_id.slice(0, 8)}`}
authorId={picture.user_id}
likes={picture.likes_count}
comments={picture.comments[0]?.count || 0}
isLiked={userLikes.has(picture.id)}
description={picture.description}
onClick={handleImageClick}
onLike={fetchUserLikes}
onDelete={performSearch}
/>
))}
</div>
);
case 'users':
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{users.map((user) => (
<Link
key={user.user_id}
to={`/user/${user.user_id}`}
className="group"
>
<div className="bg-card rounded-xl border p-6 hover:shadow-glow transition-all duration-300 hover:scale-[1.02]">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-primary rounded-full flex items-center justify-center">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.display_name || 'User avatar'}
className="w-full h-full rounded-full object-cover"
/>
) : (
<User className="h-6 w-6 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors">
{user.display_name || `User ${user.user_id.slice(0, 8)}`}
</h3>
{user.username && (
<p className="text-xs text-muted-foreground">@{user.username}</p>
)}
{user.bio && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{user.bio}
</p>
)}
</div>
</div>
</div>
</Link>
))}
</div>
);
case 'collections':
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{collections.map((collection) => (
<Link
key={collection.id}
to={`/collections/${collection.user_id}/${collection.slug}`}
className="group"
>
<div className="bg-card rounded-xl border p-6 hover:shadow-glow transition-all duration-300 hover:scale-[1.02]">
<div className="w-12 h-12 bg-gradient-primary rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Calendar className="h-6 w-6 text-white" />
</div>
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors mb-2">
{collection.name}
</h3>
{collection.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{collection.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{collection.picture_count} photos</span>
<span>{new Date(collection.created_at).toLocaleDateString()}</span>
</div>
</div>
</Link>
))}
</div>
);
case 'tags':
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{tags.map((tag) => (
<Link
key={tag}
to={`/tags/${tag}`}
className="group"
>
<div className="bg-card rounded-xl border p-4 hover:shadow-glow transition-all duration-300 hover:scale-[1.02] text-center">
<Hash className="h-8 w-8 mx-auto mb-2 text-primary group-hover:scale-110 transition-transform" />
<span className="text-sm font-medium group-hover:text-primary transition-colors">
{tag}
</span>
</div>
</Link>
))}
</div>
);
default: // 'all'
return renderMediaGrid(pictures);
default:
return (
<div className="space-y-8">
{/* Pictures Section */}
{pictures.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Pictures ({pictures.length})</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setActiveTab('pictures')}
>
View all
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{pictures.slice(0, 4).map((picture) => (
<PhotoCard
key={picture.id}
pictureId={picture.id}
image={picture.image_url}
title={picture.title}
author={`User ${picture.user_id.slice(0, 8)}`}
authorId={picture.user_id}
likes={picture.likes_count}
comments={picture.comments[0]?.count || 0}
isLiked={userLikes.has(picture.id)}
description={picture.description}
onClick={handleImageClick}
onLike={fetchUserLikes}
onDelete={performSearch}
/>
))}
</div>
</div>
)}
{/* Users Section */}
{users.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Users ({users.length})</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setActiveTab('users')}
>
View all
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{users.slice(0, 3).map((user) => (
<Link
key={user.user_id}
to={`/user/${user.user_id}`}
className="group"
>
<div className="bg-card rounded-xl border p-6 hover:shadow-glow transition-all duration-300 hover:scale-[1.02]">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-primary rounded-full flex items-center justify-center">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.display_name || 'User avatar'}
className="w-full h-full rounded-full object-cover"
/>
) : (
<User className="h-6 w-6 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm group-hover:text-primary transition-colors">
{user.display_name || `User ${user.user_id.slice(0, 8)}`}
</h3>
{user.username && (
<p className="text-xs text-muted-foreground">@{user.username}</p>
)}
</div>
</div>
</div>
</Link>
))}
</div>
</div>
)}
{/* Collections Section */}
{collections.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Collections ({collections.length})</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setActiveTab('collections')}
>
View all
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{collections.slice(0, 3).map((collection) => (
<Link
key={collection.id}
to={`/collections/${collection.user_id}/${collection.slug}`}
className="group"
>
<div className="bg-card rounded-xl border p-6 hover:shadow-glow transition-all duration-300 hover:scale-[1.02]">
<div className="w-12 h-12 bg-gradient-primary rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Calendar className="h-6 w-6 text-white" />
</div>
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors mb-2">
{collection.name}
</h3>
<div className="text-xs text-muted-foreground">
{collection.picture_count} photos
</div>
</div>
</Link>
))}
</div>
</div>
)}
{/* Tags Section */}
{tags.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Tags ({tags.length})</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setActiveTab('tags')}
>
View all
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{tags.slice(0, 6).map((tag) => (
<Link
key={tag}
to={`/tags/${tag}`}
className="group"
>
<div className="bg-card rounded-xl border p-4 hover:shadow-glow transition-all duration-300 hover:scale-[1.02] text-center">
<Hash className="h-8 w-8 mx-auto mb-2 text-primary group-hover:scale-110 transition-transform" />
<span className="text-sm font-medium group-hover:text-primary transition-colors">
{tag}
</span>
</div>
</Link>
))}
</div>
</div>
)}
{renderSection('Pages', <FileText className="h-5 w-5 text-primary" />, pages, 'pages', 4)}
{renderSection('Posts', <MessageSquare className="h-5 w-5 text-primary" />, posts, 'posts', 4)}
{renderSection('Pictures', <ImageIcon className="h-5 w-5 text-primary" />, pictures, 'pictures', 8)}
</div>
);
}
@ -496,25 +187,20 @@ const SearchResults = () => {
<div className="text-center py-16">
<Search className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">Enter a search term</h3>
<p className="text-muted-foreground">
Search for pictures, users, collections, or hashtags
</p>
<p className="text-muted-foreground">Search for pages, posts, and pictures</p>
</div>
</div>
</div>
);
}
const totalResults = getTotalResults();
return (
<div className="min-h-screen bg-background pt-14">
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
<ArrowLeft className="h-4 w-4 mr-2" /> Back
</Button>
<ThemeToggle />
</div>
@ -523,79 +209,49 @@ const SearchResults = () => {
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<Search className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">
Search results for "{query}"
</h1>
<h1 className="text-2xl font-bold">Search results for "{query}"</h1>
</div>
<p className="text-muted-foreground">
{totalResults} {totalResults === 1 ? 'result' : 'results'} found
{feedPosts.length} {feedPosts.length === 1 ? 'result' : 'results'} found
</p>
</div>
{/* Tabs */}
{totalResults > 0 && (
{feedPosts.length > 0 && (
<div className="flex justify-center mb-8">
<div className="flex bg-muted rounded-lg p-1 overflow-x-auto">
<button
onClick={() => setActiveTab('all')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'all'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${activeTab === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
All ({totalResults})
All ({feedPosts.length})
</button>
{pages.length > 0 && (
<button
onClick={() => setActiveTab('pages')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${activeTab === 'pages' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
<FileText className="h-4 w-4" /> Pages ({pages.length})
</button>
)}
{posts.length > 0 && (
<button
onClick={() => setActiveTab('posts')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${activeTab === 'posts' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
<MessageSquare className="h-4 w-4" /> Posts ({posts.length})
</button>
)}
{pictures.length > 0 && (
<button
onClick={() => setActiveTab('pictures')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'pictures'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${activeTab === 'pictures' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
<ImageIcon className="h-4 w-4" />
Pictures ({pictures.length})
</button>
)}
{users.length > 0 && (
<button
onClick={() => setActiveTab('users')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'users'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Users className="h-4 w-4" />
Users ({users.length})
</button>
)}
{collections.length > 0 && (
<button
onClick={() => setActiveTab('collections')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'collections'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Calendar className="h-4 w-4" />
Collections ({collections.length})
</button>
)}
{tags.length > 0 && (
<button
onClick={() => setActiveTab('tags')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'tags'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Hash className="h-4 w-4" />
Tags ({tags.length})
<ImageIcon className="h-4 w-4" /> Pictures ({pictures.length})
</button>
)}
</div>
@ -603,13 +259,11 @@ const SearchResults = () => {
)}
{/* Content */}
{totalResults === 0 ? (
{feedPosts.length === 0 ? (
<div className="text-center py-16">
<Search className="h-16 w-16 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No results found</h3>
<p className="text-muted-foreground">
Try searching with different keywords
</p>
<p className="text-muted-foreground">Try searching with different keywords</p>
</div>
) : (
getTabContent()