search 1/2
This commit is contained in:
parent
f99cf78e6b
commit
41fbfd2369
414
packages/ui/docs/i18n.md
Normal file
414
packages/ui/docs/i18n.md
Normal 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 |
|
||||
87
packages/ui/docs/page-commands.md
Normal file
87
packages/ui/docs/page-commands.md
Normal 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.
|
||||
84
packages/ui/docs/page-edit.md
Normal file
84
packages/ui/docs/page-edit.md
Normal 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
95
packages/ui/docs/tabs.md
Normal 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`.
|
||||
210
packages/ui/docs/type-system.md
Normal file
210
packages/ui/docs/type-system.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
125
packages/ui/src/components/CategoryTreeView.tsx
Normal file
125
packages/ui/src/components/CategoryTreeView.tsx
Normal 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;
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
31
packages/ui/src/modules/search/client-search.ts
Normal file
31
packages/ui/src/modules/search/client-search.ts
Normal 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();
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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!}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user