flex container 1/2

This commit is contained in:
lovebird 2026-02-25 10:11:54 +01:00
parent a7f4fa2ac6
commit 56564ad1e5
99 changed files with 6765 additions and 1690 deletions

View File

@ -0,0 +1,175 @@
# Cache-EX: Per-Item Cache Invalidation Architecture
> Replaces the current "nuke everything" AppCache invalidation with surgical, per-item operations.
## Problem
A single post title update currently triggers **~50+ cache invalidation events** and SSE broadcasts:
- `flushPostsCache()``appCache.invalidate('posts')` → cascades to `feed`
- `flushPicturesCache()``appCache.invalidate('pictures')` → cascades to `posts → feed`, `pages → feed`
- Each cascade node emits an SSE event
- Unknown source fires `flushPicturesCache` 9 times (once per picture row)
- Client `StreamInvalidator` receives ~20 SSE events, most for types it doesn't know (`feed`, `system`)
## Design
### Core Principle: Separate Cache Clearing from Notification
| Concern | Current | Cache-EX |
|---|---|---|
| Cache clearing | `invalidate(type)` → blast everything + SSE + cascade | `invalidate(type)` → clear cache silently |
| SSE notification | Embedded in cache clearing (N events per cascade) | `notify(type, id, action)` → 1 explicit SSE per handler |
| Client invalidation | Broad type-based (`invalidateQueries(['posts'])`) | Per-item (`invalidateQueries(['post', id])`) |
### Server Architecture
```
┌──────────────────────────┐
│ Route Handler │
│ handleUpdatePost(id) │
├──────────────────────────┤
│ 1. DB write │
│ 2. invalidate('posts') │ ← silent, no SSE
│ 3. notify('post',id, │ ← exactly 1 SSE event
│ 'update') │
└──────────────────────────┘
▼ SSE
┌──────────────────────────┐
│ StreamInvalidator │
│ event: post:abc:update │
├──────────────────────────┤
│ invalidateQueries( │
│ ['post', 'abc'] │ ← surgical
│ ) │
└──────────────────────────┘
```
### AppCache Changes
```ts
// BEFORE: invalidate() does cache clearing + SSE + cascade
invalidate(type: string): Promise<void>
// AFTER: invalidate() is silent cache clearing only
invalidate(type: string): Promise<void> // clears cache + cascade, NO SSE
notify(type: string, id: string | null, action: 'create' | 'update' | 'delete'): void // 1 SSE
```
### Event Shape
```ts
// BEFORE: { kind:'cache', type:'posts', action:'delete', data:{type:'posts'} }
// AFTER:
interface CacheEvent {
kind: 'cache';
type: string; // 'post', 'page', 'category', 'picture'
action: 'create' | 'update' | 'delete';
id: string | null; // entity ID (null = list-level change)
data?: any; // optional payload for optimistic updates
}
```
### Client StreamInvalidator
```ts
// Per-item invalidation with dependency awareness
const INVALIDATION_RULES: Record<string, (id: string | null, qc: QueryClient) => void> = {
'post': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['post', id] });
qc.invalidateQueries({ queryKey: ['posts'] });
qc.invalidateQueries({ queryKey: ['feed'] });
},
'picture': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['picture', id] });
qc.invalidateQueries({ queryKey: ['pictures'] });
},
'page': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['page', id] });
qc.invalidateQueries({ queryKey: ['pages'] });
},
'category': (id, qc) => {
qc.invalidateQueries({ queryKey: ['categories'] });
},
};
```
---
## Milestones
### M0: Revert & Baseline ✦ prerequisite
- [ ] Revert debounce changes from `cache.ts` (return to pre-investigation state)
- [ ] Revert `StreamInvalidator.tsx` changes (remove feed/system mappings, keep logging)
### M1: Silent Invalidation ✦ server-only
- [ ] `AppCache.invalidate()` → remove all `appEvents.emitUpdate()` calls
- [ ] `AppCache.flush()` → remove SSE emission
- [ ] `AppCache.notify(type, id, action)` → new method, emits exactly 1 SSE
- [ ] Remove `_isRoot` parameter (no longer needed — no cascade SSE)
- [ ] Keep dependency cascade for cache clearing (still needed server-side)
- [ ] Adjust SSE event shape to include `id` field
### M2: Handler-Level Notification ✦ server-only
- [ ] `handleUpdatePost` → add `appCache.notify('post', postId, 'update')`
- [ ] `handleCreatePost` → add `appCache.notify('post', newId, 'create')`
- [ ] `handleDeletePost` → add `appCache.notify('post', postId, 'delete')`
- [ ] `handleUpsertPictures` → add `appCache.notify('post', postId, 'update')` (pictures belong to a post)
- [ ] `handleUnlinkPictures` → add `appCache.notify('pictures', null, 'update')`
- [ ] `handleUpdatePicture` → add `appCache.notify('picture', picId, 'update')`
- [ ] `handleCreatePicture` → add `appCache.notify('picture', picId, 'create')`
- [ ] `handleDeletePicture` → add `appCache.notify('picture', picId, 'delete')`
- [ ] Page handlers (`pages-crud.ts`) → add `appCache.notify('page', pageId, ...)`
- [ ] Category handlers → add `appCache.notify('category', catId, ...)`
- [ ] Type handlers → add `appCache.notify('type', typeId, ...)`
- [ ] Layout handlers → add `appCache.notify('layout', layoutId, ...)`
- [ ] Flush-all handler → add `appCache.notify('system', null, 'delete')`
### M3: Client StreamInvalidator ✦ client-only
- [ ] Replace `EVENT_TO_QUERY_KEY` map with `INVALIDATION_RULES` (function-based)
- [ ] Parse `event.id` from SSE payload
- [ ] Per-item `invalidateQueries` when `id` is present
- [ ] Fallback to list-level invalidation when `id` is null
### M4: E2E Test ✦ verification
- [ ] Create `cache-ex.e2e.test.ts` following existing test patterns
- [ ] Test: post update → SSE emits exactly 1 event (type='post', has id)
- [ ] Test: picture create → SSE emits exactly 1 event (type='picture', has id)
- [ ] Test: category update → SSE emits exactly 1 event (type='category', has id)
- [ ] Test: no cascade SSE events (verify `feed`, `pages` events are NOT emitted)
- [ ] Test: cache is still cleared correctly (type + dependents)
- [ ] Test: flush-all → exactly 1 SSE event (type='system')
- [ ] Add `test:cache-ex` script to `package.json`
---
## Files Changed
| File | Change |
|---|---|
| `server/src/cache.ts` | Rewrite `invalidate()`, add `notify()`, remove debounce |
| `server/src/events.ts` | Update `AppEvent` interface with `id` field |
| `server/src/products/serving/db/db-posts.ts` | Add `notify()` calls to handlers |
| `server/src/products/serving/db/db-pictures.ts` | Add `notify()` calls to handlers |
| `server/src/products/serving/pages/pages-crud.ts` | Add `notify()` calls to handlers |
| `server/src/products/serving/db/db-categories.ts` | Add `notify()` calls to handlers |
| `server/src/products/serving/db/db-types.ts` | Add `notify()` calls to handlers |
| `server/src/products/serving/index.ts` | Update flush-all handler |
| `src/components/StreamInvalidator.tsx` | Replace map with function-based rules |
| `server/src/products/serving/__tests__/cache-ex.e2e.test.ts` | New test file |
| `server/package.json` | Add `test:cache-ex` script |
## Verification
### Automated (E2E)
```bash
cd server
npm run test:cache-ex
```
### Manual
1. Start dev servers (`npm run dev` in both client/server)
2. Open browser console, filter for `[StreamInvalidator]`
3. Edit a post title → save
4. Expected: exactly **1-2 SSE log lines** (`post:xyz:update`), no cascade spam
5. Run `npm run build` in server to verify TypeScript compiles

View File

@ -410,5 +410,178 @@ This keeps DeepL machine translations and human translations using the **same te
|---|---|---|
| **Phase 1** | Content versioning for pages | `content_versions` |
| **Phase 2** | Page-level translations | `content_translations` |
| **Phase 3** | Widget-level translations | `widget_translations` |
| **Phase 3** | Widget-level translations | `widget_translations` |
| **Phase 4** | Extend to posts / collections | Same tables, new `entity_type` values |
---
## Implemented Features
### Client i18n Loading (`src/i18n.tsx`)
Translations are loaded from `src/i18n/*.json` using Vite's `import.meta.glob` with `eager: true`. This ensures:
- All JSON files are statically included at build time
- Vite HMR pushes updates instantly when a JSON file changes on disk
- No stale module cache issues (unlike dynamic `import()`)
```typescript
const langModules = import.meta.glob('./i18n/*.json', { eager: true });
```
**Requested terms** (keys seen in the app but not yet translated) are cached in `localStorage` under `i18n-requested-terms`. These are merged with the loaded JSON translations, with JSON taking priority.
---
### Glossary Term Editing (DeepL v3 API)
#### API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/i18n/glossaries/:id/terms` | Fetch all terms for a glossary |
| `PUT` | `/api/i18n/glossaries/:id/terms` | Replace all terms (syncs with DeepL v3, updates DB, flushes cache) |
The PUT endpoint uses the **DeepL v3 API** (`PUT /v3/glossaries/{id}/dictionaries`) to replace the entire glossary dictionary in TSV format. It then syncs the local DB (`i18n_glossary_terms`) and updates `entry_count`.
#### Client Functions
- `fetchGlossaryTerms(glossaryId)` — fetches term pairs as `Record<string, string>`
- `updateGlossaryTerms(glossaryId, entries)` — replaces all terms
#### Playground UI
Glossaries in the management section are **expandable** — click to load and inline-edit terms. Each glossary row shows:
- Add/delete individual terms
- "Save" button (enabled only when there are unsaved changes via dirty-state detection)
---
### Glossary Selection Improvements
- **Bidirectional filter**: The glossary dropdown in the Translation section shows glossaries matching the language pair in **either direction** (e.g. when translating `en→de`, both `en→de` and `de→en` glossaries appear)
- **Direction label**: Each glossary option shows its direction: `osr (de→en, 2 entries)`
- **DeepL target lang normalization**: `en`/`EN` → `en-GB`, `pt`/`PT` → `pt-PT` (DeepL rejects bare `en`/`pt` target codes)
---
### Widget Translations
#### Schema (Actual — Deployed)
```sql
CREATE TABLE widget_translations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type text NOT NULL DEFAULT 'page',
entity_id text, -- nullable for system translations
widget_id text, -- nullable for system translations
prop_path text NOT NULL DEFAULT 'content',
source_lang text NOT NULL,
target_lang text NOT NULL,
source_text text,
translated_text text,
source_version int,
status text DEFAULT 'draft',
meta jsonb DEFAULT '{}',
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT uq_widget_translation
UNIQUE NULLS NOT DISTINCT (entity_type, entity_id, widget_id, prop_path, target_lang)
);
```
> Uses `NULLS NOT DISTINCT` so system translations (with NULL `entity_id`/`widget_id`) are still properly deduplicated. The unique constraint is required by PostgREST for upsert conflict resolution.
#### API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/i18n/widget-translations` | Query with filters: `entity_type`, `entity_id`, `widget_id`, `target_lang` |
| `PUT` | `/api/i18n/widget-translations` | Upsert single translation |
| `PUT` | `/api/i18n/widget-translations/batch` | Upsert multiple translations |
| `DELETE` | `/api/i18n/widget-translations/:id` | Delete by ID |
| `DELETE` | `/api/i18n/widget-translations/entity/:type/:id` | Delete all translations for an entity (optional `?target_lang=`) |
#### Client Functions
- `fetchWidgetTranslations(filters)` — query with optional entity/widget/lang filters
- `upsertWidgetTranslation(input)` — upsert a single translation
- `upsertWidgetTranslationsBatch(inputs)` — upsert multiple (used by "Update Database")
- `deleteWidgetTranslation(id)` — delete by ID
- `deleteWidgetTranslationsByEntity(type, id, lang?)` — bulk delete
---
### Update i18n Language Files
#### API Endpoint
| Method | Endpoint | Description |
|---|---|---|
| `PUT` | `/api/i18n/update-lang-file` | Merge translations into `src/i18n/{lang}.json` |
**Request body**: `{ lang: string, entries: Record<string, string> }`
**Behavior**:
1. Reads `CLIENT_SRC_PATH` from server `.env` (set to `../`)
2. Resolves `${CLIENT_SRC_PATH}/src/i18n/${lang}.json`
3. Reads existing file, merges new entries (skips empty values)
4. Sorts alphabetically by key
5. Writes back with `JSON.stringify(sorted, null, 2)`
6. Returns `{ success, total, added, updated }`
**Client function**: `updateLangFile(lang, entries)`
---
### Playground UI — Widget Translations Section
The i18n Playground (`/playground` → i18n tab) provides a full management UI:
#### Search & Filter
- **Entity type / Entity ID / Widget ID / Target lang** — server-side filters for querying
- **Client-side search** — filter loaded results by source or translation text (case-insensitive)
- **Show missing** toggle — filter to untranslated entries only
#### Row Selection
- **Checkbox per row** with **select-all** in header
- Selected rows get a subtle highlight
- Selection affects: batch translate, Update Database, and Update i18n
#### Batch Translation
- **Glossary picker** — select a glossary for batch translation (shows all glossaries with direction labels)
- **Translate All Missing** / **Translate Selected** — batch-translates via DeepL
- Progress indicator during batch translation
#### Persistence Actions
- 🟠 **Update Database** — batch-upserts translated entries to Supabase via `upsertWidgetTranslationsBatch`
- 🟢 **Update i18n** — merges translations into `src/i18n/{lang}.json` files (groups by `target_lang`, uses `source_text` as key)
Both buttons respect checkbox selection: if rows are selected, only those are processed; otherwise all translated entries.
#### Import from i18n
- **Import from app** — loads terms from `localStorage` requested-terms cache, cross-references with existing translations, and populates the list
#### Inline Editing
- Click any row to expand and edit source text, translated text, status, and metadata
- Single-row translate button (DeepL) in edit mode
---
### Environment Variables
| Variable | Location | Value | Purpose |
|---|---|---|---|
| `CLIENT_SRC_PATH` | `server/.env` | `../` | Path to client source root (for writing `src/i18n/*.json`) |
| `CLIENT_DIST_PATH` | `server/.env` | `../dist` | Path to client build output |
---
### E2E Tests (`i18n.e2e.test.ts`)
Tests cover:
- Glossary CRUD (create, list, get terms, update terms via DeepL v3, delete)
- Translation with glossary
- Widget translation CRUD (upsert, batch upsert, query, delete)
- Authentication checks (401 for unauthorized requests)

View File

@ -0,0 +1,642 @@
# LayoutContainer-Ex: Rows with Adjustable Columns
## Investigation Summary
The goal is to support a **row-based layout** where each row can have independently adjustable column widths (e.g. 30/70, 50/50, 33/33/34). This document analyses the current architecture, identifies every surface that would need changes, and assesses a "build new + keep old" strategy.
---
## Current Architecture
### Data Model (`LayoutManager.ts`)
```typescript
interface LayoutContainer {
id: string;
type: 'container';
columns: number; // ← single integer, drives CSS grid-cols-N
gap: number; // ← pixel gap between cells
widgets: WidgetInstance[];
children: LayoutContainer[];
order?: number;
settings?: {
collapsible?: boolean;
collapsed?: boolean;
title?: string;
showTitle?: boolean;
customClassName?: string;
enabled?: boolean;
};
}
```
**Key observations:**
| Aspect | Current behaviour |
|--------|-------------------|
| **Column model** | `columns: number` — equal-width columns only (112). Rendered via Tailwind `grid-cols-N` classes. |
| **Widget placement** | Widgets have an `order` index. The CSS grid auto-flows them left-to-right, top-to-bottom. There is no "widget belongs to column X" field. |
| **Column targeting** | `addWidgetToContainer()` accepts `targetColumn?: number` but only uses it to calculate an insertion _index_ — it is a positional hint derived from `index % columns`, not a persistent assignment. |
| **Row concept** | None. Rows are an implicit side-effect of CSS grid wrapping. |
| **Nesting** | Containers can nest up to 3 levels deep. Nested containers span `col-span-full`. |
| **Container children** | `children: LayoutContainer[]` — nested containers always appear _after_ all widgets. |
### Rendering (`LayoutContainer.tsx`)
The component builds grid classes via `getGridClasses(columns)``grid-cols-1 md:grid-cols-N`. All widgets and children are rendered inside one `<div className={gridClasses}>`. There is no per-row logic.
### Command System (`commands.ts`, `LayoutContext.tsx`)
15+ command classes (Add/Remove/Move Widget, Add/Remove/Move Container, Update Columns, Update Settings, etc.) all operate on the flat `widgets[]` array and the `containers[]` / `children[]` tree. All are undo/redo-capable via `HistoryManager`.
### Settings UI (`ContainerSettingsManager.tsx`)
Only supports: title, showTitle, collapsible, collapsed. No column-width controls.
---
## What "Rows with Adjustable Columns" Means
A new `RowLayoutContainer` (or equivalent) would treat layout as:
```
Container
└─ Row 0: [Col 40%] [Col 60%] ← 2 columns, custom widths
└─ Row 1: [Col 33%] [Col 33%] [Col 34%] ← 3 columns
└─ Row 2: [Col 100%] ← full width
```
Each row is an independent unit with its own column count and width distribution. Widgets are placed in a specific **row + column** cell, not just by order.
### Proposed Data Model
```typescript
interface RowDef {
id: string;
columnWidths: number[]; // e.g. [40, 60] or [1, 2, 1] (fr units)
gap?: number; // row-level gap override
}
interface RowLayoutContainer {
id: string;
type: 'row-layout'; // discriminator vs 'container'
rows: RowDef[];
widgets: WidgetInstance[]; // each widget needs row + column assignment
gap: number; // vertical gap between rows
order?: number;
settings?: { /* same as current + potential new fields */ };
}
```
Each `WidgetInstance` would need extended placement:
```typescript
interface WidgetInstance {
id: string;
widgetId: string;
props?: Record<string, any>;
order?: number;
// NEW — only for row-layout containers:
rowId?: string;
column?: number;
}
```
---
## UI Manipulation: Rows & Columns
### Row Bar (edit mode only)
Each row gets a thin header bar (like the existing container header), visible only in edit mode:
```
┌─ Row 1 ──────────────────────── [+ Col] [Split] [↑] [↓] [×] ─┐
│ [ Widget A ] │ [ Widget B ] │ [ Widget C ] │
└───────────────────────────────────────────────────────────────-┘
┌─ Row 2 ──────────────────────── [+ Col] [Split] [↑] [↓] [×] ─┐
│ [ Widget D ] │ [ Widget E ] │
└───────────────────────────────────────────────────────────────-┘
[ + Add Row ]
```
**Row-level actions:**
| Button | Action |
|--------|--------|
| **+ Col** | Appends a new equal-width column to this row. E.g. `[1fr, 1fr]``[1fr, 1fr, 1fr]` |
| **Split** | Splits the last column into two. E.g. `[2fr, 1fr]``[2fr, 0.5fr, 0.5fr]` |
| **↑ / ↓** | Move row up / down within the container |
| **×** | Remove row (widgets in it get orphaned → prompt: delete widgets or move to adjacent row?) |
| **+ Add Row** | Appears below the last row. Creates a new single-column row at the bottom. |
### Adding / Removing Columns per Row
Columns exist per-row, not per-container. Each row stores its own `columnWidths: number[]`.
**Adding a column:**
- Click **+ Col** on the row bar → appends `1fr` to the row's `columnWidths`.
- Or right-click a column divider → "Insert column left / right".
- New column starts empty.
**Removing a column:**
- Right-click a column or its divider → "Remove column".
- If the column contains widgets: prompt to move them to the adjacent column (left preference) or delete them.
- If it's the last column: row becomes single-column, not removed.
- Removing the last column of the last row does NOT auto-remove the row (explicit delete required).
**Merging columns:**
- Select two adjacent columns (shift-click column headers?) → "Merge" action.
- Merges `columnWidths` entries by summing them: `[1fr, 2fr, 1fr]` → merge cols 1+2 → `[3fr, 1fr]`.
- Widgets from both columns are stacked vertically in the merged column in their original order.
### Column Width Adjustment
**Drag handles** between columns:
```
│ Col 1 (2fr) ┃↔┃ Col 2 (1fr) │ Col 3 (1fr) │
drag handle
```
- A vertical drag handle between each pair of adjacent columns.
- Dragging redistributes width between the two neighbors only (the rest stay fixed).
- Minimum column width: `0.5fr` or `50px` — prevents collapse to zero.
- The handle shows a subtle resize cursor on hover.
- During drag: live preview with a ghost overlay showing the new widths.
- On release: `columnWidths` array is updated, triggers `ResizeRowColumnsCommand` (undoable).
**Presets** (accessible from row bar dropdown or right-click):
| Preset | `columnWidths` |
|--------|---------------|
| Equal halves | `[1, 1]` |
| Equal thirds | `[1, 1, 1]` |
| Sidebar left | `[1, 3]` |
| Sidebar right | `[3, 1]` |
| Golden ratio | `[1.618, 1]` |
| Wide center | `[1, 2, 1]` |
| Custom... | Opens a text input for arbitrary `fr` values |
### Row Reordering
- **↑ / ↓ buttons** on the row bar for keyboard-friendly reordering.
- Potential: **drag-to-reorder** via a grip handle on the row bar's left edge (lower priority, buttons first).
---
## Sizing Modes
Each column cell can operate in one of two sizing modes. This controls the **height behaviour** of the cell and therefore the row.
### Constrained Mode (default)
```
columnWidths: [1, 2, 1]
sizing: 'constrained' // or simply: no override
```
- The row's height is dictated by the **tallest cell** in the row (CSS grid default `align-items: stretch`).
- Each cell stretches to match the row height.
- Widgets inside a cell fill available space or scroll if they overflow.
- **Use case:** Traditional grid layouts, dashboards, side-by-side cards of equal height.
CSS implementation:
```css
.row {
display: grid;
grid-template-columns: 1fr 2fr 1fr; /* from columnWidths */
align-items: stretch; /* all cells same height */
}
```
### Unconstrained Mode (content-driven)
```
sizing: 'unconstrained'
```
- Each cell's height is determined by its own content (**intrinsic sizing**).
- Cells in the same row can have different heights — the row shrinks to fit.
- The row height equals the tallest cell, but shorter cells do **not** stretch (they align to top).
- **Use case:** Content blocks where one column has a short heading and the other has a long article — you don't want the short column to have a huge empty gap.
CSS implementation:
```css
.row {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: start; /* cells don't stretch */
}
```
### Per-Cell Override (stretch / start / center / end)
For finer control, each cell could also have its own vertical alignment:
| Value | Behaviour |
|-------|-----------|
| `stretch` | Cell fills row height (default in constrained mode) |
| `start` | Cell sticks to top, height = content |
| `center` | Cell vertically centered within row |
| `end` | Cell sticks to bottom |
This maps directly to CSS `align-self` on the grid cell.
### Width Sizing: `fr` vs Fixed
Column widths can mix `fr` (fractional) and fixed (px / rem) units:
```typescript
interface ColumnDef {
width: number;
unit: 'fr' | 'px' | 'rem' | '%';
}
```
| Scenario | `columnWidths` | CSS |
|----------|---------------|-----|
| All flexible | `[1fr, 2fr, 1fr]` | `grid-template-columns: 1fr 2fr 1fr` |
| Fixed sidebar | `[250px, 1fr]` | `grid-template-columns: 250px 1fr` |
| Mixed | `[200px, 1fr, 300px]` | `grid-template-columns: 200px 1fr 300px` |
Fixed columns **don't resize** on drag — the drag handle only redistributes the `fr`-based columns. Fixed columns can be resized via the settings panel or by double-clicking the drag handle to convert to `fr`.
### Mobile Responsive Collapse
All sizing modes collapse to `grid-template-columns: 1fr` on mobile (`< md` breakpoint), matching the existing container behaviour. Per-row mobile overrides (e.g. "keep 2 columns on tablet") are a possible future extension but not required for v1.
### Data Model Update for Sizing
```typescript
interface RowDef {
id: string;
columns: ColumnDef[]; // replaces simple columnWidths: number[]
gap?: number;
sizing?: 'constrained' | 'unconstrained'; // row-level default
cellAlignments?: ('stretch' | 'start' | 'center' | 'end')[]; // per-cell override
}
interface ColumnDef {
width: number;
unit: 'fr' | 'px' | 'rem' | '%';
minWidth?: number; // collapse protection, in px
}
```
### Settings UI for Sizing
In the row settings (accessible from the row bar ⚙ or the ContainerSettingsManager):
- **Row sizing mode** toggle: Constrained / Unconstrained
- **Per-cell alignment** dropdown (only visible in unconstrained mode)
- **Column width type** per column: `fr` / `px` / `%` with numeric input
- **Column min-width** (optional, safety net)
---
## CSS Implementation: Grid vs Flexbox
### Why the Current Container Uses Tailwind Grid
The existing `LayoutContainer` builds classes like `grid grid-cols-1 md:grid-cols-3 gap-4`. This works because:
- **Equal-width columns only** → Tailwind's `grid-cols-N` maps directly to `grid-template-columns: repeat(N, minmax(0, 1fr))`.
- **Auto-flow** → Widgets land in the next available cell. No explicit row/column assignment needed.
- **Responsive**`md:grid-cols-3` collapses to `grid-cols-1` on mobile for free.
But Tailwind grid classes **cannot** express arbitrary column widths like `1fr 2fr 1fr` or `250px 1fr`. There's no `grid-cols-[1fr_2fr_1fr]` utility without JIT arbitrary values, and even then it gets messy with responsive variants.
### Row-Layout: Inline `style` for Column Widths
For arbitrary column widths, we **must** use inline `style` on the row div. Tailwind remains useful for everything else (gap, alignment, responsive collapse, padding).
```tsx
// Row renderer — one per RowDef
const RowRenderer: React.FC<{ row: RowDef; children: React.ReactNode }> = ({ row, children }) => {
// Build grid-template-columns from ColumnDef[]
const gridTemplateColumns = row.columns
.map(col => `${col.width}${col.unit}`)
.join(' ');
// e.g. "1fr 2fr 1fr" or "250px 1fr 300px"
return (
<div
className={cn(
"grid min-w-0", // Tailwind: grid layout
"gap-4", // Tailwind: gap (or from row.gap)
row.sizing === 'unconstrained'
? "items-start" // Tailwind: align-items: start
: "items-stretch", // Tailwind: align-items: stretch
// Responsive: collapse to single column on mobile
"max-md:!grid-cols-1"
)}
style={{
gridTemplateColumns, // Inline: arbitrary widths
gap: row.gap !== undefined ? `${row.gap}px` : undefined,
}}
>
{children}
</div>
);
};
```
**Key pattern:** Tailwind for behaviour/layout mode + inline `style` for dynamic values that can't be known at build time.
### Where Tailwind Still Works
| Concern | Tailwind class | Notes |
|---------|---------------|-------|
| Grid mode | `grid` | Always grid, never flex for the row itself |
| Gap | `gap-2`, `gap-4`, etc | For fixed gap values; use inline `style.gap` for custom px |
| Alignment | `items-start`, `items-stretch`, `items-center`, `items-end` | Row-level vertical alignment |
| Per-cell align | `self-start`, `self-center`, `self-end`, `self-stretch` | On the cell wrapper div |
| Responsive collapse | `max-md:grid-cols-1` or `grid-cols-1 md:grid-cols-none` | Override inline columns on mobile |
| Min heights | `min-h-[80px]` | Empty cell placeholders in edit mode |
| Overflow | `min-w-0 overflow-hidden` | Prevent blowoff from wide content |
| Edit mode borders | `border-2 border-blue-500` | Selection highlighting |
### Where Tailwind Does NOT Work (Must Use Inline Style)
| Concern | Why | Solution |
|---------|-----|----------|
| `grid-template-columns` | Arbitrary `fr`/`px`/`%` combos unknown at build time | `style={{ gridTemplateColumns }}` |
| Row gap (custom px) | Dynamic per-row value | `style={{ gap: '${row.gap}px' }}` |
| Drag-resize preview | Changes every frame during drag | `style={{ gridTemplateColumns }}` updated via `useState` or ref |
### Why Flexbox is Worse for the Row Grid
Flexbox _could_ theoretically work for column layout, but it has hard disadvantages for this use case:
| Aspect | CSS Grid | Flexbox |
|--------|----------|---------|
| **Arbitrary column widths** | `grid-template-columns: 1fr 2fr 1fr` — one property | Must set `flex-basis` + `flex-grow` on each child. `flex: 2 1 0%` for 2fr. Fragile. |
| **Equal-height cells** | `align-items: stretch` works by default | Works too (`align-items: stretch`), but only for single-row flex. Multi-line flex (`flex-wrap`) breaks this. |
| **Cell alignment overrides** | `align-self: start` on cell | Works the same way ✓ |
| **Gap** | `gap: 16px` — native grid gap | `gap` works in modern browsers, but older flex shims needed `margin` hacks |
| **Mixed units** | `250px 1fr 300px` — trivial | Need calc: `flex: 0 0 250px` for fixed, `flex: 1 1 0%` for fr. No native mixing. |
| **Content overflow** | `minmax(0, 1fr)` prevents blowout | `min-width: 0` on each child, plus `flex-shrink` tuning |
| **Responsive collapse** | Override `grid-template-columns: 1fr` | Must flip `flex-direction: column` AND reset all flex-basis values |
| **Drag-resize** | Update one `style.gridTemplateColumns` string | Must update N child styles simultaneously |
**Verdict:** CSS Grid is clearly superior for the row-based approach. The only thing flex is better at is natural content-flow wrapping, which we don't want — we want explicit column control.
### Flexbox: Where It IS Useful
Flex is still the right tool **inside cells**, not for the row grid itself:
```tsx
// Cell wrapper — uses flex for vertical widget stacking within a cell
const CellRenderer: React.FC<{ alignment?: string; children: React.ReactNode }> = ({ alignment, children }) => (
<div
className={cn(
"flex flex-col min-w-0 overflow-hidden", // Flex column for vertical widget stack
"gap-2", // Gap between stacked widgets
alignment === 'center' && "self-center",
alignment === 'end' && "self-end",
alignment === 'start' && "self-start",
// Default: self-stretch (from parent grid's items-stretch)
)}
>
{children}
</div>
);
```
Each cell is a **flex column** that stacks multiple widgets vertically. The grid is only for the row itself.
### Full Row-Layout Renderer Skeleton
```tsx
const RowLayoutRenderer: React.FC<{ container: RowLayoutContainer; isEditMode: boolean }> = ({
container,
isEditMode,
}) => {
return (
<div className="space-y-0">
{container.rows.map((row, rowIndex) => {
// Build grid-template-columns
const gtc = row.columns.map(c => `${c.width}${c.unit}`).join(' ');
return (
<div key={row.id}>
{/* Row header bar (edit mode only) */}
{isEditMode && (
<RowHeaderBar row={row} rowIndex={rowIndex} />
)}
{/* Row grid */}
<div
className={cn(
"grid min-w-0",
row.sizing === 'unconstrained' ? "items-start" : "items-stretch",
// Mobile: force single column
"max-md:!grid-cols-1",
)}
style={{ gridTemplateColumns: gtc, gap: `${row.gap ?? container.gap}px` }}
>
{row.columns.map((col, colIndex) => {
// Find widgets assigned to this row + column
const cellWidgets = container.widgets
.filter(w => w.rowId === row.id && w.column === colIndex)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const cellAlign = row.cellAlignments?.[colIndex];
return (
<div
key={`${row.id}-${colIndex}`}
className={cn(
"flex flex-col min-w-0 overflow-hidden gap-2",
cellAlign === 'start' && "self-start",
cellAlign === 'center' && "self-center",
cellAlign === 'end' && "self-end",
// edit mode: empty cell placeholder
isEditMode && cellWidgets.length === 0 && "min-h-[80px] border border-dashed border-slate-300",
)}
style={{ minWidth: col.minWidth ? `${col.minWidth}px` : undefined }}
>
{cellWidgets.map(widget => (
<WidgetItem key={widget.id} widget={widget} /* ...props */ />
))}
</div>
);
})}
{/* Drag handles between columns (edit mode only) */}
{isEditMode && row.columns.length > 1 && (
<ColumnDragHandles row={row} onResize={handleColumnResize} />
)}
</div>
</div>
);
})}
{/* Add Row button */}
{isEditMode && (
<button className="w-full py-2 text-sm text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20">
+ Add Row
</button>
)}
</div>
);
};
```
### Drag Handle Implementation Notes
The column drag handles sit **on top of** the grid as absolutely positioned elements, not as grid children (they'd break the column count):
```tsx
const ColumnDragHandles: React.FC<{ row: RowDef; onResize: (rowId: string, colIndex: number, delta: number) => void }> = ({
row, onResize
}) => {
// Positioned absolutely over the row. One handle between each pair of columns.
// Each handle is a thin vertical bar at the column boundary.
return (
<div className="absolute inset-0 pointer-events-none">
{row.columns.slice(0, -1).map((_, i) => {
// Calculate left offset: sum of column widths up to (i+1)
// For fr units, convert to percentages based on total fr
const totalFr = row.columns.reduce((sum, c) => sum + (c.unit === 'fr' ? c.width : 0), 0);
const leftFr = row.columns.slice(0, i + 1).reduce((sum, c) => sum + (c.unit === 'fr' ? c.width : 0), 0);
const leftPercent = (leftFr / totalFr) * 100;
return (
<div
key={i}
className="absolute top-0 bottom-0 w-1 cursor-col-resize pointer-events-auto
hover:bg-blue-400 active:bg-blue-500 transition-colors z-10"
style={{ left: `${leftPercent}%`, transform: 'translateX(-50%)' }}
onMouseDown={(e) => handleDragStart(e, row.id, i)}
/>
);
})}
</div>
);
};
```
> [!NOTE]
> The `left` percentage calculation above is simplified for all-`fr` rows. Mixed `fr`/`px` rows need the browser's computed column positions via `getComputedStyle()` or `getBoundingClientRect()` on the cell elements.
### Responsive Collapse Pattern
```css
/* Row grid: arbitrary columns on desktop, single column on mobile */
.row-grid {
display: grid;
/* grid-template-columns set via inline style */
}
@media (max-width: 768px) {
.row-grid {
grid-template-columns: 1fr !important; /* Override inline style */
}
}
```
In Tailwind, this is `max-md:!grid-cols-1` (with `!` for `!important` to override inline styles). This is the **one place** where `!important` is justified — inline styles normally win specificity, but responsive collapse must override them.
---
## Impact Analysis
### 🟢 No-impact (if we keep old `LayoutContainer` as-is)
| Component | Reason |
|-----------|--------|
| All existing saved page data | Old pages use `type: 'container'`, unaffected |
| `LayoutContainerWidget.tsx` | Embeds `GenericCanvas`, which dispatches per container type |
| Existing undo/redo commands for old containers | Untouched |
### 🟡 Needs extension (dual support)
| Component | What changes |
|-----------|-------------|
| **`LayoutManager.ts`** | New `RowLayoutContainer` interface, new `type` discriminator. `UnifiedLayoutManager` needs: `addRowToContainer()`, `removeRow()`, `resizeRowColumns()`, `findContainerOrRowLayout()`. `addWidgetToContainer` needs a row-layout branch. |
| **`GenericCanvas.tsx`** | Must render `RowLayoutContainer` alongside `LayoutContainer`. Fork rendering by `container.type`. |
| **`PageLayout`** | `containers: (LayoutContainer \| RowLayoutContainer)[]` — union type. |
| **`commands.ts`** | New commands: `AddRowCommand`, `RemoveRowCommand`, `ResizeRowColumnsCommand`, `MoveWidgetToRowCellCommand`. Existing `MoveWidgetCommand` needs row-awareness. |
| **`LayoutContext.tsx`** | New context methods: `addRow`, `removeRow`, `resizeRowColumns`, `moveWidgetToCell`. Wire to new commands. |
| **Widget Palette / Add Widget flow** | Must know if targeting a row-layout and prompt for row + column. |
| **`ContainerSettingsManager.tsx`** | Row-layout variant needs row editor (add/remove rows, drag column dividers). |
| **`ContainerPropertyPanel.tsx`** | Show row structure for selected row-layout container. |
### 🔴 High-complexity areas
| Area | Complexity | Notes |
|------|-----------|-------|
| **Column width drag UI** | High | Need a drag-handle between columns per row, with live preview. Could use CSS `resize` or custom drag logic. |
| **Widget move across rows** | Medium | `MoveWidgetCommand` currently moves by `order ± 1`. In row-layout, moving down/up means crossing row boundaries, moving left/right means changing column. Completely different semantics. |
| **Export / Import** | Low-Medium | `exportPageLayout` / `importPageLayout` already serialise the full tree. A new `type` field will survive JSON round-trip, but import from old format needs migration. |
| **AI Layout Wizard** | Low | `BatchAddContainersCommand` generates `LayoutContainer[]`. Would need an equivalent for row-layouts or teach the AI about the new format. |
| **Clipboard (copy/paste)** | Medium | `SelectionContext` and clipboard logic need to handle the new type. |
| **SSR / Email / PDF rendering** | Medium | These use `iterateWidgets()` from `@polymech/shared`. That utility would need row-layout traversal support. |
---
## Strategy: Build New, Keep Old
**Recommended.** The cleanest path:
1. **Add discriminated union** on `type: 'container' | 'row-layout'` in the data model.
2. **Build `RowLayoutContainer.tsx`** as a new renderer alongside the existing one.
3. **Fork in `GenericCanvas.tsx`** based on container type.
4. **New commands** — keep all existing commands untouched.
5. **Add "Row Layout" to Widget Palette** as a new container type option.
6. **ContainerSettingsManager** gets a second mode for row-layout configuration.
### What we DON'T have to change (back-compat)
- Existing `LayoutContainer` type, rendering, and commands remain untouched.
- Existing page data (`type: 'container'`) loads and renders as before.
- Old `getGridClasses()` stays as-is.
### What we MUST build (new)
| # | Deliverable | Est. LoC |
|---|-------------|----------|
| 1 | `RowDef` / `RowLayoutContainer` types in `LayoutManager.ts` | ~30 |
| 2 | `RowLayoutContainer.tsx` — rendering component | ~300 |
| 3 | `GenericCanvas.tsx` — type fork for rendering | ~20 |
| 4 | Row-specific commands (Add/Remove/Resize Row, Move Widget to Cell) | ~300 |
| 5 | `LayoutContext.tsx` — new context methods + wiring | ~100 |
| 6 | Row editor UI (column width dragger per row) | ~250 |
| 7 | `ContainerSettingsManager` row-layout mode or separate component | ~150 |
| 8 | `iterateWidgets` update in `@polymech/shared` | ~20 |
| 9 | Widget Palette "Add Row Layout" option | ~20 |
| **Total** | | **~1200** |
---
## Alternative: Extend Current Container
Instead of a new type, we could add `columnWidths?: number[]` to existing `LayoutContainer` and make each row an implicit `LayoutContainer` child. Pros: less new code. Cons:
- Pollutes existing model with optional fields that only matter for row mode.
- Every existing command and renderer gains branches.
- Risk of subtle bugs in existing pages if `columnWidths` accidentally gets set.
- Harder to reason about; "container" means two very different things.
**Verdict:** Not recommended. The discriminated union approach is safer and cleaner.
---
## Open Questions
1. **Column width units** — pixels, percentages, or CSS `fr` units? `fr` is most flexible for responsive design (`grid-template-columns: 1fr 2fr 1fr`).
2. **Max rows per container?** — Unbounded or capped?
3. **Should rows be re-orderable?** — Drag-to-reorder rows, or just add/remove?
4. **Mobile collapse** — Do multi-column rows stack to 1-column on mobile (like current), or allow per-row mobile config?
5. **Can a row-layout contain nested containers?** — Probably yes (in a cell), but adds complexity.
6. **Migration path** — Should there be a "Convert container → row-layout" action?
---
## Conclusion
Building a new `RowLayoutContainer` alongside the existing `LayoutContainer` is the safest and cleanest approach. It avoids touching ~2000 lines of working container code + commands, while adding ~1200 lines of new purpose-built code. The main complexity is in the **column-width drag UI** and **widget movement semantics** across row/column cells. Everything else is mechanical wiring.

View File

@ -61,12 +61,12 @@ cd ../polymech-mono/packages/acl && npm run build
```env
TEST_EMAIL="cgoflyn@gmail.com" ( admin user)
TEST_PASSWORD="213,,asd"
TEST_PASSWORD="...."
TEST_USER_ID="cgo"
TEST_EMAIL_REGULAR="sales@plastic-hub.com" (regular user)
TEST_PASSWORD_REGULAR="213,,asd"
TEST_PASSWORD_REGULAR="..."
VITE_SUPABASE_URL="https://…supabase.co"

View File

@ -1,18 +1,18 @@
{
"name": "@polymech/pm-pics-shared",
"name": "@polymech/shared",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@polymech/pm-pics-shared",
"name": "@polymech/shared",
"version": "1.0.0",
"dependencies": {
"@hono/zod-openapi": "^1.1.5"
"@hono/zod-openapi": "^1.1.5",
"zod": "^4.3.6"
},
"devDependencies": {
"typescript": "^5.0.0",
"zod": "^4.1.12"
"typescript": "^5.0.0"
}
},
"../../../polymech-mono/packages/commons": {
@ -135,9 +135,9 @@
}
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {

View File

@ -16,6 +16,6 @@
},
"dependencies": {
"@hono/zod-openapi": "^1.1.5",
"zod": "^3.24.1"
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,220 @@
export interface AppConfig {
site: Site;
i18n: I18N;
cad: Cad;
core: Core;
footer_left: FooterLeft[];
footer_right: any[];
settings: Settings;
params: Params;
navigation: Navigation;
navigation_button: NavigationButton;
ecommerce: Ecommerce;
metadata: Metadata;
shopify: Shopify;
pages: Pages;
dev: Dev;
features: { [key: string]: boolean };
retail: Retail;
osrl: Osrl;
products: Products;
defaults: Defaults;
assets: Assets;
optimization: Optimization;
}
export interface Assets {
local: boolean;
glob: string;
cad_url: string;
url: string;
item_url_r: string;
item_url: string;
}
export interface Cad {
cache: boolean;
export_configurations: boolean;
export_sub_components: boolean;
renderer: string;
renderer_view: string;
renderer_quality: number;
extensions: string[];
model_ext: string;
default_configuration: string;
main_match: string;
cam_main_match: string;
}
export interface Core {
logging_namespace: string;
translate_content: boolean;
languages: string[];
languages_prod: string[];
rtl_languages: string[];
osr_root: string;
}
export interface Defaults {
image_url: string;
license: string;
contact: string;
}
export interface Dev {
file_server: string;
}
export interface Ecommerce {
brand: string;
currencySymbol: string;
currencyCode: string;
}
export interface FooterLeft {
href: string;
text: string;
}
export interface I18N {
store: string;
cache: boolean;
source_language: string;
asset_path: string;
}
export interface Metadata {
country: string;
city: string;
author: string;
author_bio: string;
author_url: string;
image: string;
description: string;
keywords: string;
}
export interface Navigation {
top: FooterLeft[];
}
export interface NavigationButton {
enable: boolean;
label: string;
link: string;
}
export interface Optimization {
image_settings: ImageSettings;
presets: Presets;
}
export interface ImageSettings {
gallery: Gallery;
lightbox: Gallery;
}
export interface Gallery {
show_title: boolean;
show_description: boolean;
sizes_thumb: string;
sizes_large: string;
sizes_regular: string;
}
export interface Presets {
slow: Fast;
medium: Fast;
fast: Fast;
}
export interface Fast {
sizes_medium: string;
sizes_thumbs: string;
sizes_large: string;
}
export interface Osrl {
env: string;
env_dev: string;
module_name: string;
lang_flavor: string;
product_profile: string;
}
export interface Pages {
home: Home;
}
export interface Home {
hero: string;
_blog: Blog;
}
export interface Blog {
store: string;
}
export interface Params {
contact_form_action: string;
copyright: string;
}
export interface Products {
root: string;
howto_migration: string;
glob: string;
enabled: string;
}
export interface Retail {
products: string;
library_branch: string;
projects_branch: string;
compile_cache: boolean;
media_cache: boolean;
log_level_i18n_product_assets: string;
convert_product_media: boolean;
translate_product_assets: boolean;
populate_product_defaults: boolean;
}
export interface Settings {
search: boolean;
account: boolean;
sticky_header: boolean;
theme_switcher: boolean;
default_theme: string;
}
export interface Shopify {
currencySymbol: string;
currencyCode: string;
collections: Collections;
}
export interface Collections {
hero_slider: string;
featured_products: string;
}
export interface Site {
title: string;
base_url: string;
description: string;
base_path: string;
trailing_slash: boolean;
favicon: string;
logo: string;
logo_darkmode: string;
logo_width: string;
logo_height: string;
logo_text: string;
image: Image;
}
export interface Image {
default: string;
error: string;
alt: string;
}

View File

@ -0,0 +1,225 @@
// Generated by ts-to-zod
import { z } from "zod";
export const i18nSchema = z.object({
store: z.string(),
cache: z.boolean(),
source_language: z.string(),
asset_path: z.string(),
});
export const cadSchema = z.object({
cache: z.boolean(),
export_configurations: z.boolean(),
export_sub_components: z.boolean(),
renderer: z.string(),
renderer_view: z.string(),
renderer_quality: z.number(),
extensions: z.array(z.string()),
model_ext: z.string(),
default_configuration: z.string(),
main_match: z.string(),
cam_main_match: z.string(),
});
export const coreSchema = z.object({
logging_namespace: z.string(),
translate_content: z.boolean(),
languages: z.array(z.string()),
languages_prod: z.array(z.string()),
rtl_languages: z.array(z.string()),
osr_root: z.string(),
});
export const footerLeftSchema = z.object({
href: z.string(),
text: z.string(),
});
export const settingsSchema = z.object({
search: z.boolean(),
account: z.boolean(),
sticky_header: z.boolean(),
theme_switcher: z.boolean(),
default_theme: z.string(),
});
export const paramsSchema = z.object({
contact_form_action: z.string(),
copyright: z.string(),
});
export const navigationSchema = z.object({
top: z.array(footerLeftSchema),
});
export const navigationButtonSchema = z.object({
enable: z.boolean(),
label: z.string(),
link: z.string(),
});
export const ecommerceSchema = z.object({
brand: z.string(),
currencySymbol: z.string(),
currencyCode: z.string(),
});
export const metadataSchema = z.object({
country: z.string(),
city: z.string(),
author: z.string(),
author_bio: z.string(),
author_url: z.string(),
image: z.string(),
description: z.string(),
keywords: z.string(),
});
export const devSchema = z.object({
file_server: z.string(),
});
export const retailSchema = z.object({
products: z.string(),
library_branch: z.string(),
projects_branch: z.string(),
compile_cache: z.boolean(),
media_cache: z.boolean(),
log_level_i18n_product_assets: z.string(),
convert_product_media: z.boolean(),
translate_product_assets: z.boolean(),
populate_product_defaults: z.boolean(),
});
export const osrlSchema = z.object({
env: z.string(),
env_dev: z.string(),
module_name: z.string(),
lang_flavor: z.string(),
product_profile: z.string(),
});
export const productsSchema = z.object({
root: z.string(),
howto_migration: z.string(),
glob: z.string(),
enabled: z.string(),
});
export const defaultsSchema = z.object({
image_url: z.string(),
license: z.string(),
contact: z.string(),
});
export const assetsSchema = z.object({
local: z.boolean(),
glob: z.string(),
cad_url: z.string(),
url: z.string(),
item_url_r: z.string(),
item_url: z.string(),
});
export const gallerySchema = z.object({
show_title: z.boolean(),
show_description: z.boolean(),
sizes_thumb: z.string(),
sizes_large: z.string(),
sizes_regular: z.string(),
});
export const fastSchema = z.object({
sizes_medium: z.string(),
sizes_thumbs: z.string(),
sizes_large: z.string(),
});
export const blogSchema = z.object({
store: z.string(),
});
export const collectionsSchema = z.object({
hero_slider: z.string(),
featured_products: z.string(),
});
export const imageSchema = z.object({
default: z.string(),
error: z.string(),
alt: z.string(),
});
export const siteSchema = z.object({
title: z.string(),
base_url: z.string(),
description: z.string(),
base_path: z.string(),
trailing_slash: z.boolean(),
favicon: z.string(),
logo: z.string(),
logo_darkmode: z.string(),
logo_width: z.string(),
logo_height: z.string(),
logo_text: z.string(),
image: imageSchema,
});
export const shopifySchema = z.object({
currencySymbol: z.string(),
currencyCode: z.string(),
collections: collectionsSchema,
});
export const imageSettingsSchema = z.object({
gallery: gallerySchema,
lightbox: gallerySchema,
});
export const presetsSchema = z.object({
slow: fastSchema,
medium: fastSchema,
fast: fastSchema,
});
export const homeSchema = z.object({
hero: z.string(),
_blog: blogSchema,
});
export const pagesSchema = z.object({
home: homeSchema,
});
export const optimizationSchema = z.object({
image_settings: imageSettingsSchema,
presets: presetsSchema,
});
export const appConfigSchema = z.object({
site: siteSchema,
i18n: i18nSchema,
cad: cadSchema,
core: coreSchema,
footer_left: z.array(footerLeftSchema),
footer_right: z.array(z.any()),
settings: settingsSchema,
params: paramsSchema,
navigation: navigationSchema,
navigation_button: navigationButtonSchema,
ecommerce: ecommerceSchema,
metadata: metadataSchema,
shopify: shopifySchema,
pages: pagesSchema,
dev: devSchema,
features: z.record(z.string(), z.boolean()),
retail: retailSchema,
osrl: osrlSchema,
products: productsSchema,
defaults: defaultsSchema,
assets: assetsSchema,
optimization: optimizationSchema,
});
export type AppConfig = z.infer<typeof appConfigSchema>;

View File

@ -1,4 +1,4 @@
import { z } from 'zod';
import { z } from 'zod/v4';
// =========================================
// Utility Enums & Common Schemas
@ -58,10 +58,6 @@ export const PhotoCardWidgetSchema = z.object({
pictureId: z.string().nullable().default(null),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(true),
showAuthor: z.boolean().default(true),
showActions: z.boolean().default(true),
showTitle: z.boolean().default(true),
showDescription: z.boolean().default(true),
contentDisplay: ContentDisplaySchema.default('below'),
imageFit: ImageFitSchema.default('cover'),
variables: WidgetVariablesSchema,
@ -260,14 +256,14 @@ export type RootLayoutData = z.infer<typeof RootLayoutDataSchema>;
/////////////////////////////////////////////////////////////////////////////////////
const PageBaseSchema = z.object({
id: z.string().guid().optional(),
id: z.string().uuid().optional(),
title: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
owner: z.string().guid(),
owner: z.string().uuid(),
content: RootLayoutDataSchema.loose().optional(), // Enforce RootLayout structure for new/updated pages
is_public: z.boolean().optional(),
visible: z.boolean().optional(),
parent: z.string().guid().nullable().optional(),
parent: z.string().uuid().nullable().optional(),
meta: z.any().optional(), // JSONB
created_at: z.string().optional(),
updated_at: z.string().optional()
@ -284,14 +280,14 @@ export const UpdatePageSchema = PageBaseSchema.partial().omit({ id: true, owner:
// --- User Page Details Schemas ---
export const UserProfileSchema = z.object({
user_id: z.string().guid(),
user_id: z.string().uuid(),
username: z.string(),
display_name: z.string().nullable().optional(),
avatar_url: z.string().nullable().optional(),
});
export const SimplePageSchema = z.object({
id: z.string().guid(),
id: z.string().uuid(),
title: z.string(),
slug: z.string(),
visible: z.boolean().optional(),

View File

@ -4,8 +4,8 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
import { BrowserRouter, Routes, Route, useLocation, useNavigate } from "react-router-dom";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth";
import { PostNavigationProvider } from "@/contexts/PostNavigationContext";
import { OrganizationProvider } from "@/contexts/OrganizationContext";
import { LogProvider } from "@/contexts/LogContext";
@ -24,21 +24,22 @@ registerAllWidgets();
import Index from "./pages/Index";
import Auth from "./pages/Auth";
const UpdatePassword = React.lazy(() => import("./pages/UpdatePassword"));
import Profile from "./pages/Profile";
import Post from "./pages/Post";
const Post = React.lazy(() => import("./pages/Post"));
import UserProfile from "./pages/UserProfile";
import UserCollections from "./pages/UserCollections";
import Collections from "./pages/Collections";
const Collections = React.lazy(() => import("./pages/Collections"));
import NewCollection from "./pages/NewCollection";
const UserPage = React.lazy(() => import("./modules/pages/UserPage"));
import NewPage from "./modules/pages/NewPage";
import NewPost from "./pages/NewPost";
const NewPost = React.lazy(() => import("./pages/NewPost"));
import TagPage from "./pages/TagPage";
import SearchResults from "./pages/SearchResults";
import Wizard from "./pages/Wizard";
const Wizard = React.lazy(() => import("./pages/Wizard"));
import Organizations from "./pages/Organizations";
import LogsPage from "./components/logging/LogsPage";
@ -85,23 +86,24 @@ const AppWrapper = () => {
{/* Top-level routes (no organization context) */}
<Route path="/" element={<Index />} />
<Route path="/auth" element={<Auth />} />
<Route path="/auth/update-password" element={<React.Suspense fallback={<div>Loading...</div>}><UpdatePassword /></React.Suspense>} />
<Route path="/profile" element={<Profile />} />
<Route path="/post/:id" element={<Post />} />
<Route path="/video/:id" element={<Post />} />
<Route path="/post/:id" element={<React.Suspense fallback={<div>Loading...</div>}><Post /></React.Suspense>} />
<Route path="/video/:id" element={<React.Suspense fallback={<div>Loading...</div>}><Post /></React.Suspense>} />
<Route path="/user/:userId" element={<UserProfile />} />
<Route path="/user/:userId/collections" element={<UserCollections />} />
<Route path="/user/:userId/pages/new" element={<NewPage />} />
<Route path="/user/:username/pages/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><UserPage /></React.Suspense>} />
<Route path="/collections/new" element={<NewCollection />} />
<Route path="/collections/:userId/:slug" element={<Collections />} />
<Route path="/collections/:userId/:slug" element={<React.Suspense fallback={<div>Loading...</div>}><Collections /></React.Suspense>} />
<Route path="/tags/:tag" element={<TagPage />} />
<Route path="/latest" element={<Index />} />
<Route path="/top" element={<Index />} />
<Route path="/categories" element={<Index />} />
<Route path="/categories/:slug" element={<Index />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/wizard" element={<Wizard />} />
<Route path="/new" element={<NewPost />} />
<Route path="/wizard" element={<React.Suspense fallback={<div>Loading...</div>}><Wizard /></React.Suspense>} />
<Route path="/new" element={<React.Suspense fallback={<div>Loading...</div>}><NewPost /></React.Suspense>} />
<Route path="/version-map/:id" element={
<React.Suspense fallback={<div className="flex items-center justify-center h-screen">Loading map...</div>}>
<VersionMap />

View File

@ -145,7 +145,6 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
onNavigateHistory,
}) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
console.log(e.key);
// Ctrl+Enter to generate
if ((e.key === 'Enter' && e.ctrlKey) && prompt.trim() && !isGenerating) {
e.preventDefault();
@ -228,7 +227,7 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
</Label>
</div>
<Badge variant={imageToolsEnabled ? 'default' : 'secondary'} className="text-xs">
{imageToolsEnabled ? 'Enabled' : 'Text Only'}
{imageToolsEnabled ? <T>Enabled</T> : <T>Text Only</T>}
</Badge>
</div>
@ -246,7 +245,7 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
</Label>
</div>
<Badge variant={webSearchEnabled ? 'default' : 'secondary'} className="text-xs">
{webSearchEnabled ? 'On' : 'Off'}
{webSearchEnabled ? <T>On</T> : <T>Off</T>}
</Badge>
</div>
@ -264,7 +263,7 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
<Label htmlFor="ctx-clear" className="text-sm cursor-pointer">
<T>Prompt Only</T>
<Badge variant="outline" className="ml-2 text-xs text-muted-foreground font-normal">
No context
<T>No context</T>
</Badge>
</Label>
</div>
@ -280,7 +279,7 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
</Badge>
) : (
<Badge variant="outline" className="ml-2 text-xs text-muted-foreground font-normal">
None
<T>None</T>
</Badge>
)}
</Label>
@ -297,7 +296,7 @@ export const AITextGenerator: React.FC<AITextGeneratorProps> = ({
</Badge>
) : (
<Badge variant="outline" className="ml-2 text-xs text-muted-foreground font-normal">
Empty
<T>Empty</T>
</Badge>
)}
</Label>

View File

@ -3,8 +3,9 @@ import { fetchCategories, type Category } from "@/modules/categories/client-cate
import { useNavigate, useParams } from "react-router-dom";
import { cn } from "@/lib/utils";
import { useState, useCallback, useMemo } from "react";
import { FolderTree, ChevronRight, ChevronDown, Home, Loader2 } from "lucide-react";
import { FolderTree, ChevronRight, ChevronDown, Loader2 } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useAppConfig } from '@/hooks/useSystemInfo';
interface CategoryTreeViewProps {
/** Called after a category is selected (useful for closing mobile sheet) */
@ -40,9 +41,12 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) =>
} catch { return new Set(); }
});
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language;
const { data: categories = [], isLoading } = useQuery({
queryKey: ['categories'],
queryFn: () => fetchCategories({ includeChildren: true }),
queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang }),
staleTime: 1000 * 60 * 5,
});
@ -133,19 +137,6 @@ const CategoryTreeView = ({ onNavigate, filterType }: CategoryTreeViewProps) =>
return (
<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 */}
{filteredCategories.map(cat => renderNode(cat))}
</nav>

View File

@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Button } from '@/components/ui/button';
import { T, translate } from '@/i18n';
import { Image, FilePlus, Zap, Mic, Loader2, Upload, Video, Layers, BookPlus, Plus } from 'lucide-react';
import { useImageWizard } from '@/hooks/useImageWizard';
import { usePageGenerator } from '@/hooks/usePageGenerator';
import VoiceRecordingPopup from './VoiceRecordingPopup';
@ -15,7 +15,6 @@ import PostPicker from './PostPicker';
import { useWizardContext } from '@/hooks/useWizardContext';
import { usePromptHistory } from '@/hooks/usePromptHistory';
import { useAuth } from '@/hooks/useAuth';
import { useOrganization } from '@/contexts/OrganizationContext';
import { useMediaRefresh } from '@/contexts/MediaRefreshContext';
import { createPicture } from '@/modules/posts/client-pictures';
import { fetchPostById } from '@/modules/posts/client-posts';

View File

@ -8,8 +8,6 @@ import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent } from '@/components/ui/card';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { supabase } from '@/integrations/supabase/client';
import { updatePicture } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
@ -29,13 +27,11 @@ interface Collection {
is_public: boolean;
}
const editSchema = z.object({
title: z.string().max(100, 'Title must be less than 100 characters').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters').optional(),
visible: z.boolean(),
});
type EditFormData = z.infer<typeof editSchema>;
interface EditFormData {
title?: string;
description?: string;
visible: boolean;
}
interface EditImageModalProps {
open: boolean;
@ -74,7 +70,6 @@ const EditImageModal = ({
const [loadingCollections, setLoadingCollections] = useState(false);
const form = useForm<EditFormData>({
resolver: zodResolver(editSchema),
defaultValues: {
title: currentTitle,
description: currentDescription || '',
@ -405,8 +400,8 @@ const EditImageModal = ({
onClick={handleMicrophone}
disabled={isTranscribing || updating}
className={`p-1.5 rounded-md transition-colors ${isRecording
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={translate(isRecording ? 'Stop recording' : 'Record audio')}
>
@ -510,8 +505,8 @@ const EditImageModal = ({
<Card
key={collection.id}
className={`cursor-pointer transition-colors ${selectedCollections.has(collection.id)
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
? 'bg-primary/10 border-primary'
: 'hover:bg-muted/50'
}`}
onClick={() => handleToggleCollection(collection.id)}
>

View File

@ -0,0 +1,86 @@
import { useAppConfig } from '@/hooks/useSystemInfo';
const policyLinks = [
{ label: "Returns & Refunds", href: "/returns" },
{ label: "Shipping", href: "/shipping" },
{ label: "Privacy Policy", href: "/privacy" },
{ label: "Terms of Service", href: "/terms" },
];
const Footer = () => {
const config = useAppConfig();
if (!config) return null;
const { footer_left = [], footer_right = [], metadata } = config;
const allLinks = [...footer_left, ...footer_right];
return (
<footer className="border-t border-border/40 bg-muted/30 mt-auto">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{/* Column 1: Navigation links */}
{allLinks.length > 0 && (
<nav className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/60 mb-1">Links</span>
{allLinks.map((link, i) => {
const isExternal = link.href.startsWith('http');
return (
<a
key={i}
href={link.href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.text}
</a>
);
})}
</nav>
)}
{/* Column 2: Policy links */}
<nav className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/60 mb-1">Policies</span>
{policyLinks.map((l) => (
<a
key={l.href}
href={l.href}
className="text-sm text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{l.label}
</a>
))}
</nav>
{/* Column 3: Copyright / meta */}
<div className="flex flex-col gap-1 text-xs text-muted-foreground sm:items-end">
{metadata?.author && (
<span>
© {new Date().getFullYear()}{' '}
{metadata.author_url ? (
<a
href={metadata.author_url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors underline underline-offset-2"
>
{metadata.author}
</a>
) : (
metadata.author
)}
</span>
)}
{metadata?.description && (
<span className="max-w-xs sm:text-right">{metadata.description}</span>
)}
</div>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@ -1,17 +1,14 @@
import MediaCard from "./MediaCard";
import React, { useEffect, useState, useRef } from "react";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
import { usePostNavigation } from "@/hooks/usePostNavigation";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useFeedData } from "@/hooks/useFeedData";
import { normalizeMediaType, isVideoType } from "@/lib/mediaRegistry";
import * as db from '../pages/Post/db';
import type { MediaItem } from "@/types";
import { supabase } from "@/integrations/supabase/client";
// Duplicate types for now or we could reuse specific generic props
// To minimalize refactoring PhotoGrid, I'll copy the logic but use the Feed variant
import { fetchUserMediaLikes } from "@/modules/posts/client-pictures";
import { T } from '@/i18n';
import type { FeedSortOption } from '@/hooks/useFeedData';
import { mapFeedPostsToMediaItems } from "@/modules/posts/client-posts";
@ -86,36 +83,20 @@ const GalleryLarge = ({
}
}, [feedPosts, feedLoading, customPictures, customLoading, navigationSource, navigationSourceId, setNavigationData, sortBy]);
const fetchUserLikes = async () => {
const refreshUserLikes = useCallback(async () => {
if (!user || mediaItems.length === 0) return;
try {
// Collect IDs to check (picture_id for feed, id for collection/direct pictures)
const targetIds = mediaItems
.map(item => item.picture_id || item.id)
.filter(Boolean) as string[];
if (targetIds.length === 0) return;
// Fetch likes only for the displayed items
const { data: likesData, error } = await supabase
.from('likes')
.select('picture_id')
.eq('user_id', user.id)
.in('picture_id', targetIds);
if (error) throw error;
// Merge new likes with existing set
setUserLikes(prev => {
const newSet = new Set(prev);
likesData?.forEach(l => newSet.add(l.picture_id));
return newSet;
});
const { pictureLikes } = await fetchUserMediaLikes(user.id);
setUserLikes(pictureLikes);
} catch (error) {
console.error('Error fetching user likes:', error);
}
};
}, [user, mediaItems.length]);
// Fetch likes on mount and whenever the media list changes
useEffect(() => {
refreshUserLikes();
}, [refreshUserLikes]);
const handleError = () => {
window.location.reload();
@ -125,7 +106,7 @@ const GalleryLarge = ({
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
Loading gallery...
<T>Loading gallery...</T>
</div>
</div>
);
@ -135,8 +116,8 @@ const GalleryLarge = ({
return (
<div className="py-8">
<div className="text-center text-muted-foreground">
<p className="text-lg">No media yet!</p>
<p>Be the first to share content with the community.</p>
<p className="text-lg"><T>No media yet!</T></p>
<p><T>Be the first to share content with the community.</T></p>
</div>
</div>
)
@ -165,7 +146,7 @@ const GalleryLarge = ({
type={itemType}
meta={item.meta}
onClick={() => navigate(`/post/${item.id}`)}
onLike={fetchUserLikes}
onLike={refreshUserLikes}
onDelete={handleError}
created_at={item.created_at}
job={item.job}

View File

@ -1,110 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Camera, Search, Heart, User, Upload, Bell, LogOut, ListFilter } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { Link } from "react-router-dom";
import UploadModal from "./UploadModal";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useLog } from "@/contexts/LogContext";
const Header = () => {
const { user, signOut } = useAuth();
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const { isLoggerVisible, setLoggerVisible } = useLog();
return (
<>
<header className="fixed top-0 w-full z-50 bg-glass border-b border-glass backdrop-blur-glass">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<div className="p-2 bg-gradient-primary rounded-xl shadow-glow">
<Camera className="h-6 w-6 text-white" />
</div>
<h1 className="text-xl font-bold bg-gradient-primary bg-clip-text text-transparent">
TauriPics
</h1>
</Link>
{/* Search Bar */}
<div className="hidden md:flex items-center max-w-md w-full mx-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search photos, users, collections..."
className="w-full pl-10 pr-4 py-2 bg-muted border border-border rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Navigation */}
<div className="flex items-center space-x-2">
<ThemeToggle />
<Button
variant="ghost"
size="sm"
onClick={() => setLoggerVisible(!isLoggerVisible)}
className={isLoggerVisible ? "text-primary" : ""}
title="Toggle Logger"
>
<ListFilter className="h-4 w-4" />
</Button>
{user ? (
<>
<Button variant="ghost" size="sm" className="hidden md:flex">
<Heart className="h-4 w-4 mr-2" />
Favorites
</Button>
<Button variant="ghost" size="sm">
<Bell className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setUploadModalOpen(true)}
>
<Upload className="h-4 w-4 mr-2" />
Upload
</Button>
<Link to="/profile">
<Button variant="ghost" size="sm">
<User className="h-4 w-4 mr-2" />
Profile
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={signOut}
className="text-red-500 hover:text-red-600"
>
<LogOut className="h-4 w-4" />
</Button>
</>
) : (
<Link to="/auth">
<Button size="sm" className="bg-gradient-primary text-white border-0 hover:opacity-90">
<User className="h-4 w-4 mr-2" />
Sign In
</Button>
</Link>
)}
</div>
</div>
</div>
</header>
{user && (
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onUploadSuccess={() => window.location.reload()}
/>
)}
</>
);
};
export default Header;

View File

@ -1,117 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Camera, Upload, Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { Link } from "react-router-dom";
import UploadModal from "./UploadModal";
import heroImage from "@/assets/hero-image.jpg";
const HeroSection = () => {
const { user } = useAuth();
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const handleStartSharing = () => {
if (user) {
setUploadModalOpen(true);
}
};
return (
<>
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<img
src={heroImage}
alt="Photography background"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-hero"></div>
</div>
{/* Floating Elements */}
<div className="absolute inset-0 z-10">
<div className="absolute top-20 left-10 w-20 h-20 bg-gradient-primary rounded-full opacity-20 animate-pulse"></div>
<div className="absolute top-40 right-20 w-32 h-32 bg-gradient-secondary rounded-full opacity-30 animate-pulse delay-1000"></div>
<div className="absolute bottom-40 left-20 w-16 h-16 bg-accent rounded-full opacity-25 animate-pulse delay-500"></div>
</div>
{/* Content */}
<div className="relative z-20 text-center max-w-4xl mx-auto px-4">
<div className="mb-6">
<div className="inline-flex items-center space-x-2 bg-glass border border-glass rounded-full px-4 py-2 backdrop-blur-glass mb-6">
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Share your world through photos</span>
</div>
</div>
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">
<span className="bg-gradient-primary bg-clip-text text-transparent">
Capture
</span>{" "}
<span className="text-foreground">& Share</span>
<br />
<span className="text-foreground">Your</span>{" "}
<span className="bg-gradient-secondary bg-clip-text text-transparent">
Stories
</span>
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
Join millions of photographers sharing their passion. Discover breathtaking moments,
connect with creators, and showcase your unique perspective to the world.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
{user ? (
<Button
size="lg"
className="bg-gradient-primary text-white border-0 hover:opacity-90 shadow-glow"
onClick={handleStartSharing}
>
<Upload className="h-5 w-5 mr-2" />
Start Sharing
</Button>
) : (
<Link to="/auth">
<Button size="lg" className="bg-gradient-primary text-white border-0 hover:opacity-90 shadow-glow">
<Upload className="h-5 w-5 mr-2" />
Start Sharing
</Button>
</Link>
)}
<Button variant="outline" size="lg" className="bg-glass border-glass backdrop-blur-glass hover:bg-muted">
<Camera className="h-5 w-5 mr-2" />
Explore Photos
</Button>
</div>
<div className="mt-12 flex items-center justify-center space-x-8 text-sm text-muted-foreground">
<div className="text-center">
<div className="text-2xl font-bold text-foreground">10M+</div>
<div>Photos Shared</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">500K+</div>
<div>Active Users</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">1M+</div>
<div>Daily Views</div>
</div>
</div>
</div>
</section>
{user && (
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onUploadSuccess={() => window.location.reload()}
/>
)}
</>
);
};
export default HeroSection;

View File

@ -1,7 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { ImageFile } from '../types';
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
import { ArrowUp } from 'lucide-react';
import { downloadImage, generateFilename } from '@/utils/downloadUtils';
import { toast } from 'sonner';
import { translate } from '@/i18n';
@ -25,10 +24,10 @@ interface ImageGalleryProps {
setErrorMessage?: (message: string | null) => void;
}
export default function ImageGallery({
images,
onImageSelection,
onImageRemove,
export default function ImageGallery({
images,
onImageSelection,
onImageRemove,
onImageDelete,
onImageSaveAs,
showSelection = false,
@ -115,7 +114,7 @@ export default function ImageGallery({
setSkipDeleteConfirm(true);
}
onImageDelete?.(images[safeIndex].path);
// Close lightbox if this was the last image or adjust index
if (images.length <= 1) {
setLightboxOpen(false);
@ -140,7 +139,7 @@ export default function ImageGallery({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!lightboxOpen) return;
if (event.key === 'Escape') {
setLightboxOpen(false);
} else if (event.key === 'ArrowRight' && currentIndex < images.length - 1) {
@ -162,7 +161,7 @@ export default function ImageGallery({
const preloadImage = (index: number) => {
if (images.length === 0 || index < 0 || index >= images.length) return;
setLightboxLoaded(false);
const img = new Image();
img.src = images[index].src;
@ -230,152 +229,150 @@ export default function ImageGallery({
return (
<div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Main Image Display */}
<div className="lg:col-span-2">
<div className="flex items-center justify-center rounded-lg">
<div className="relative w-full h-[300px] flex items-center justify-center">
<img
src={currentImage.src}
alt={currentImage.path}
className={`max-h-[300px] max-w-full object-contain shadow-lg border-2 transition-all duration-300 cursor-pointer ${
isSelected
? 'border-primary shadow-primary/30'
: isGenerated
? 'border-green-300'
: 'border-border'
}`}
onDoubleClick={() => openLightbox(safeIndex)}
title="Double-click for fullscreen"
/>
<div className="absolute top-2 left-2 flex flex-col gap-2">
{/* Compact overlays */}
{isGenerated && isSelected && (
<div className="bg-primary text-primary-foreground px-2 py-1 rounded text-xs font-semibold shadow-lg flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{isGenerated && !isSelected && (
<div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg">
</div>
)}
</div>
</div>
</div>
{/* Image Info */}
<div className="text-center mt-3">
<p className="text-sm text-muted-foreground">
{currentImage.path.split(/[/\\]/).pop()} {safeIndex + 1}/{images.length}
</p>
</div>
</div>
{/* Right column: Thumbnails */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Images ({images.length})</h4>
<div className="grid grid-cols-2 gap-2">
{images.map((image, index) => {
const thumbIsGenerating = image.path.startsWith('generating_');
const thumbIsGenerated = !!image.isGenerated;
const thumbIsSelected = image.selected || false;
return (
<button
type="button"
key={image.path}
onClick={(e) => handleThumbnailClick(e, image.path, index)}
onDoubleClick={() => {
if (onDoubleClick) {
onDoubleClick(image.path);
} else {
openLightbox(index);
}
}}
className={`group relative aspect-square overflow-hidden transition-all duration-300 border-2 ${
currentIndex === index
? 'ring-2 ring-primary border-primary'
: thumbIsSelected
? 'border-primary ring-2 ring-primary/30'
: thumbIsGenerated
? 'border-green-300 hover:border-primary/50'
: 'border-border hover:border-border/80'
}`}
title={
thumbIsGenerated
? "Generated image - click to select/view"
: "Click to view"
}
>
<img
src={image.src}
alt={image.path}
className="w-full h-full object-cover"
/>
{/* Selection indicator */}
{thumbIsGenerated && thumbIsSelected && (
<div className="absolute top-1 left-1 bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center">
{/* Left column: Main Image Display */}
<div className="lg:col-span-2">
<div className="flex items-center justify-center rounded-lg">
<div className="relative w-full h-[300px] flex items-center justify-center">
<img
src={currentImage.src}
alt={currentImage.path}
className={`max-h-[300px] max-w-full object-contain shadow-lg border-2 transition-all duration-300 cursor-pointer ${isSelected
? 'border-primary shadow-primary/30'
: isGenerated
? 'border-green-300'
: 'border-border'
}`}
onDoubleClick={() => openLightbox(safeIndex)}
title="Double-click for fullscreen"
/>
<div className="absolute top-2 left-2 flex flex-col gap-2">
{/* Compact overlays */}
{isGenerated && isSelected && (
<div className="bg-primary text-primary-foreground px-2 py-1 rounded text-xs font-semibold shadow-lg flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{/* Generated indicator */}
{thumbIsGenerated && !thumbIsSelected && (
<div className="absolute top-1 left-1 bg-green-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
{isGenerated && !isSelected && (
<div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold shadow-lg">
</div>
)}
{/* Save button */}
{!thumbIsGenerating && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDownloadImage(image);
}}
className="absolute bottom-1 left-1 bg-primary/70 hover:bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs transition-all duration-200"
title="Download Image"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
</div>
</div>
</div>
{/* Delete Button */}
{!thumbIsGenerating && onImageDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Are you sure you want to permanently delete this file? This action cannot be undone.')) {
onImageDelete(image.path);
}
}}
className="absolute bottom-1 right-1 bg-destructive/80 hover:bg-destructive text-destructive-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs transition-all duration-200"
title="Delete File Permanently"
>
×
</button>
)}
</button>
);
})}
{/* Image Info */}
<div className="text-center mt-3">
<p className="text-sm text-muted-foreground">
{currentImage.path.split(/[/\\]/).pop()} {safeIndex + 1}/{images.length}
</p>
</div>
</div>
{/* Right column: Thumbnails */}
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Images ({images.length})</h4>
<div className="grid grid-cols-2 gap-2">
{images.map((image, index) => {
const thumbIsGenerating = image.path.startsWith('generating_');
const thumbIsGenerated = !!image.isGenerated;
const thumbIsSelected = image.selected || false;
return (
<button
type="button"
key={image.path}
onClick={(e) => handleThumbnailClick(e, image.path, index)}
onDoubleClick={() => {
if (onDoubleClick) {
onDoubleClick(image.path);
} else {
openLightbox(index);
}
}}
className={`group relative aspect-square overflow-hidden transition-all duration-300 border-2 ${currentIndex === index
? 'ring-2 ring-primary border-primary'
: thumbIsSelected
? 'border-primary ring-2 ring-primary/30'
: thumbIsGenerated
? 'border-green-300 hover:border-primary/50'
: 'border-border hover:border-border/80'
}`}
title={
thumbIsGenerated
? "Generated image - click to select/view"
: "Click to view"
}
>
<img
src={image.src}
alt={image.path}
className="w-full h-full object-cover"
/>
{/* Selection indicator */}
{thumbIsGenerated && thumbIsSelected && (
<div className="absolute top-1 left-1 bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
{/* Generated indicator */}
{thumbIsGenerated && !thumbIsSelected && (
<div className="absolute top-1 left-1 bg-green-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
</div>
)}
{/* Save button */}
{!thumbIsGenerating && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDownloadImage(image);
}}
className="absolute bottom-1 left-1 bg-primary/70 hover:bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs transition-all duration-200"
title="Download Image"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
)}
{/* Delete Button */}
{!thumbIsGenerating && onImageDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Are you sure you want to permanently delete this file? This action cannot be undone.')) {
onImageDelete(image.path);
}
}}
className="absolute bottom-1 right-1 bg-destructive/80 hover:bg-destructive text-destructive-foreground rounded-full w-5 h-5 flex items-center justify-center text-xs transition-all duration-200"
title="Delete File Permanently"
>
×
</button>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
{/* Lightbox Modal */}
{lightboxOpen && (
<div
<div
className="fixed inset-0 bg-black/95 z-[9999] flex items-center justify-center"
onMouseDown={(e) => {
panStartRef.current = { x: e.clientX, y: e.clientY };
@ -384,7 +381,7 @@ export default function ImageGallery({
onMouseMove={(e) => {
if (panStartRef.current) {
const distance = Math.sqrt(
Math.pow(e.clientX - panStartRef.current.x, 2) +
Math.pow(e.clientX - panStartRef.current.x, 2) +
Math.pow(e.clientY - panStartRef.current.y, 2)
);
if (distance > 5) { // 5px threshold for pan detection
@ -432,7 +429,7 @@ export default function ImageGallery({
<div className="w-12 h-12 border-4 border-white/30 border-t-white rounded-full animate-spin"></div>
</div>
)}
{/* Close Button */}
<button
onClick={(e) => {
@ -444,7 +441,7 @@ export default function ImageGallery({
>
×
</button>
{/* Navigation Buttons */}
{safeIndex > 0 && (
<button
@ -462,7 +459,7 @@ export default function ImageGallery({
</button>
)}
{safeIndex < images.length - 1 && (
<button
onClick={(e) => {
@ -479,7 +476,7 @@ export default function ImageGallery({
</button>
)}
{/* Info */}
{lightboxLoaded && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/75 text-white px-4 py-2 rounded-lg text-sm">
@ -489,7 +486,7 @@ export default function ImageGallery({
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div
<div
className="absolute inset-0 bg-black/90 flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === 'Enter') {

View File

@ -557,7 +557,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
const handlePresetClear = () => {
setSelectedPreset(null);
toast.info('Preset context cleared');
toast.info(translate('Preset context cleared'));
};
const handleVoiceToImage = (transcribedText: string) =>
@ -715,7 +715,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
// Validate
const invalid = editingActions.find(a => !a.name.trim() || !a.prompt.trim());
if (invalid) {
toast.error('All actions must have a name and prompt');
toast.error(translate('All actions must have a name and prompt'));
return;
}

View File

@ -1,5 +1,6 @@
import React from 'react';
import { T } from '@/i18n';
import { AVAILABLE_MODELS, getModelString } from '@/lib/image-router';
interface ModelSelectorProps {

View File

@ -1,15 +1,15 @@
import React, { useState, useEffect } from "react";
import { useFeedData, FeedSortOption } from "@/hooks/useFeedData";
import { useOrganization } from "@/contexts/OrganizationContext";
import { useIsMobile } from "@/hooks/use-mobile";
import { useNavigate } from "react-router-dom";
import { formatDistanceToNow } from "date-fns";
import { MessageCircle, Heart, ExternalLink } from "lucide-react";
import UserAvatarBlock from "@/components/UserAvatarBlock";
import { Button } from "@/components/ui/button";
import Post from "@/pages/Post";
import UserPage from "@/modules/pages/UserPage";
const Post = React.lazy(() => import("@/pages/Post"));
const UserPage = React.lazy(() => import("@/modules/pages/UserPage"));
interface ListLayoutProps {
sortBy?: FeedSortOption;
@ -222,21 +222,25 @@ export const ListLayout = ({
if (postAny?.type === 'page-intern' && slug) {
return (
<UserPage
userId={postAny.user_id}
slug={slug}
embedded
/>
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
<UserPage
userId={postAny.user_id}
slug={slug}
embedded
/>
</React.Suspense>
);
}
return (
<Post
key={selectedId} // Force remount on ID change
postId={selectedId}
embedded
className="h-[inherit] overflow-y-auto scrollbar-custom"
/>
<React.Suspense fallback={<div className="h-full flex items-center justify-center text-muted-foreground">Loading...</div>}>
<Post
key={selectedId}
postId={selectedId}
embedded
className="h-[inherit] overflow-y-auto scrollbar-custom"
/>
</React.Suspense>
);
})()
) : (

View File

@ -46,6 +46,10 @@ interface PhotoCardProps {
imageFit?: 'contain' | 'cover';
className?: string; // Allow custom classes from parent
preset?: CardPreset;
showAuthor?: boolean;
showActions?: boolean;
showTitle?: boolean;
showDescription?: boolean;
}
const PhotoCard = ({
@ -75,7 +79,11 @@ const PhotoCard = ({
isExternal = false,
imageFit = 'contain',
className,
preset
preset,
showAuthor = true,
showActions = true,
showTitle = true,
showDescription = true
}: PhotoCardProps) => {
const { user } = useAuth();
const navigate = useNavigate();
@ -371,7 +379,7 @@ const PhotoCard = ({
<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">
{showHeader && (
{showHeader && showAuthor && (
<div className="flex items-center space-x-2">
<UserAvatarBlock
userId={authorId}
@ -383,7 +391,7 @@ const PhotoCard = ({
</div>
)}
<div className="flex items-center space-x-1">
{!isExternal && (
{showActions && !isExternal && (
<>
<Button
size="sm"
@ -446,8 +454,8 @@ const PhotoCard = ({
</div>
</div>
{!isLikelyFilename(title) && <h3 className="text-white font-medium mb-1">{title}</h3>}
{description && (
{showTitle && !isLikelyFilename(title) && <h3 className="text-white font-medium mb-1">{title}</h3>}
{showDescription && description && (
<div className="text-white/80 text-sm mb-1 line-clamp-2 overflow-hidden">
<MarkdownRenderer content={description} className="prose-invert prose-white" />
</div>
@ -458,44 +466,46 @@ const PhotoCard = ({
</div>
)}
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="secondary"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-3 w-3 mr-1" />
<T>Save</T>
</Button>
<Button
size="sm"
variant="secondary"
className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white"
onClick={(e) => {
e.stopPropagation();
handleLightboxOpen();
}}
title="View in lightbox"
>
<Maximize className="h-2.5 w-2.5" />
</Button>
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
<Share2 className="h-2.5 w-2.5" />
</Button>
{!isExternal && (
<MagicWizardButton
imageUrl={image}
imageTitle={title}
{showActions && (
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
variant="secondary"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
/>
)}
</div>
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-3 w-3 mr-1" />
<T>Save</T>
</Button>
<Button
size="sm"
variant="secondary"
className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white"
onClick={(e) => {
e.stopPropagation();
handleLightboxOpen();
}}
title="View in lightbox"
>
<Maximize className="h-2.5 w-2.5" />
</Button>
<Button size="sm" variant="secondary" className="h-6 w-6 p-0 bg-white/20 hover:bg-white/30 border-0 text-white">
<Share2 className="h-2.5 w-2.5" />
</Button>
{!isExternal && (
<MagicWizardButton
imageUrl={image}
imageTitle={title}
size="sm"
variant="ghost"
className="h-6 px-2 text-xs bg-white/20 hover:bg-white/30 border-0 text-white"
/>
)}
</div>
)}
</div>
</div>
)}
@ -506,82 +516,86 @@ const PhotoCard = ({
{/* Row 1: User Avatar (Left) + Actions (Right) */}
<div className="flex items-center justify-between px-2 pt-2">
{/* User Avatar Block */}
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author === 'User' ? undefined : author}
className="w-8 h-8"
showDate={false}
/>
{showAuthor && (
<UserAvatarBlock
userId={authorId}
avatarUrl={authorAvatarUrl}
displayName={author === 'User' ? undefined : author}
className="w-8 h-8"
showDate={false}
/>
)}
{/* Actions */}
<div className="flex items-center gap-1">
{!isExternal && (
<>
<Button
size="icon"
variant="ghost"
onClick={handleLike}
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
>
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
{localLikes > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
)}
{showActions && (
<div className="flex items-center gap-1">
{!isExternal && (
<>
<Button
size="icon"
variant="ghost"
onClick={handleLike}
className={localIsLiked ? "text-red-500 hover:text-red-600" : ""}
>
<Heart className="h-6 w-6" fill={localIsLiked ? "currentColor" : "none"} />
</Button>
{localLikes > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{localLikes}</span>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
>
<MessageCircle className="h-6 w-6 -rotate-90" />
</Button>
{comments > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
)}
</>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
>
<MessageCircle className="h-6 w-6 -rotate-90" />
</Button>
{comments > 0 && (
<span className="text-sm font-medium text-foreground mr-1">{comments}</span>
)}
</>
)}
<Button
size="icon"
variant="ghost"
className="text-foreground"
onClick={(e) => {
e.stopPropagation();
handleDownload();
}}
>
<Download className="h-6 w-6" />
</Button>
{!isExternal && (
<MagicWizardButton
imageUrl={image}
imageTitle={title}
size="icon"
variant="ghost"
className="text-foreground hover:text-primary"
/>
)}
{isOwner && !isExternal && (
<Button
size="icon"
variant="ghost"
className="text-foreground"
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
onEdit(pictureId);
} else {
setShowEditModal(true);
}
handleDownload();
}}
className="text-foreground hover:text-green-400"
>
<Edit3 className="h-6 w-6" />
<Download className="h-6 w-6" />
</Button>
)}
</div>
{!isExternal && (
<MagicWizardButton
imageUrl={image}
imageTitle={title}
size="icon"
variant="ghost"
className="text-foreground hover:text-primary"
/>
)}
{isOwner && !isExternal && (
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
onEdit(pictureId);
} else {
setShowEditModal(true);
}
}}
className="text-foreground hover:text-green-400"
>
<Edit3 className="h-6 w-6" />
</Button>
)}
</div>
)}
</div>
{/* Likes */}
@ -589,11 +603,11 @@ const PhotoCard = ({
{/* Caption / Description section */}
<div className="px-4 space-y-1">
{(!isLikelyFilename(title) && title) && (
{showTitle && (!isLikelyFilename(title) && title) && (
<div className="font-semibold text-sm">{title}</div>
)}
{description && (
{showDescription && description && (
<div className="text-sm text-foreground/90 line-clamp-3">
<MarkdownRenderer content={description} className="prose-sm dark:prose-invert" />
</div>

View File

@ -1,19 +1,61 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useQueryClient, QueryClient } from '@tanstack/react-query';
import { useStream } from '@/contexts/StreamContext';
// Mapping of AppEvent.type to React Query Keys
// This acts as a configuration template for all auto-invalidations.
const EVENT_TO_QUERY_KEY: Record<string, string[]> = {
'categories': ['categories'],
'posts': ['posts'],
'pages': ['pages'],
'users': ['users'],
'organizations': ['organizations'],
'pictures': ['pictures'],
'types': ['types'],
'likes': ['likes'],
'i18n': ['i18n'],
/**
* Per-item invalidation rules.
* Each handler receives the entity ID (or null for list-level changes)
* and the QueryClient. It decides which React Query keys to invalidate.
*/
const INVALIDATION_RULES: Record<string, (id: string | null, qc: QueryClient) => void> = {
'post': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['post', id] });
qc.invalidateQueries({ queryKey: ['posts'] });
qc.invalidateQueries({ queryKey: ['pages'] }); // posts can embed in pages
qc.invalidateQueries({ queryKey: ['feed'] });
},
'picture': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['picture', id] });
qc.invalidateQueries({ queryKey: ['pictures'] });
qc.invalidateQueries({ queryKey: ['posts'] }); // posts depend on pictures
qc.invalidateQueries({ queryKey: ['pages'] }); // pages depend on pictures
},
'pictures': (_id, qc) => {
// Batch operations (upsert/unlink/delete) — no per-item ID
qc.invalidateQueries({ queryKey: ['pictures'] });
qc.invalidateQueries({ queryKey: ['posts'] });
qc.invalidateQueries({ queryKey: ['pages'] });
qc.invalidateQueries({ queryKey: ['feed'] });
},
'page': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['page', id] });
qc.invalidateQueries({ queryKey: ['pages'] });
qc.invalidateQueries({ queryKey: ['feed'] });
},
'category': (_id, qc) => {
qc.invalidateQueries({ queryKey: ['categories'] });
qc.invalidateQueries({ queryKey: ['pages'] }); // pages depend on categories
qc.invalidateQueries({ queryKey: ['feed'] });
},
'type': (_id, qc) => {
qc.invalidateQueries({ queryKey: ['types'] });
qc.invalidateQueries({ queryKey: ['categories'] }); // categories depend on types
qc.invalidateQueries({ queryKey: ['pages'] }); // → pages
},
'layout': (id, qc) => {
if (id) qc.invalidateQueries({ queryKey: ['layout', id] });
qc.invalidateQueries({ queryKey: ['layouts'] });
},
'glossary': (_id, qc) => {
qc.invalidateQueries({ queryKey: ['i18n'] });
},
'i18n': (_id, qc) => {
qc.invalidateQueries({ queryKey: ['i18n'] });
},
'system': (_id, qc) => {
// Full flush — invalidate everything
qc.invalidateQueries();
},
};
export const StreamInvalidator = () => {
@ -22,16 +64,15 @@ export const StreamInvalidator = () => {
useEffect(() => {
const unsubscribe = subscribe((event) => {
// Verify it's a cache invalidation event
if (event.kind === 'cache' && event.type) {
const queryKey = EVENT_TO_QUERY_KEY[event.type];
const handler = INVALIDATION_RULES[event.type];
const eventId = event.id ?? event.data?.id ?? null;
if (queryKey) {
console.log(`[StreamInvalidator] Invalidating query key: ${queryKey} for event type: ${event.type}`);
queryClient.invalidateQueries({ queryKey });
if (handler) {
console.log(`[StreamInvalidator] ${event.type}:${eventId ?? '*'}:${event.action}`);
handler(eventId, queryClient);
} else {
// Optional: Log unhandled types if you want to verify what's missing
console.log(`[StreamInvalidator] Unknown event type for invalidation: ${event.type}`);
console.log(`[StreamInvalidator] Unhandled event type: ${event.type}`);
}
}
});
@ -39,5 +80,5 @@ export const StreamInvalidator = () => {
return unsubscribe;
}, [subscribe, queryClient]);
return null; // This component handles logic only, no UI
return null;
};

View File

@ -1,6 +1,5 @@
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { useOrganization } from "@/contexts/OrganizationContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -16,15 +15,14 @@ import {
} from "@/components/ui/dropdown-menu";
import { useNavigate } from "react-router-dom";
import { useProfiles } from "@/contexts/ProfilesContext";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, lazy, Suspense } from "react";
import { T, getCurrentLang, supportedLanguages, translate, setLanguage } from "@/i18n";
import { CreationWizardPopup } from './CreationWizardPopup';
const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m => ({ default: m.CreationWizardPopup })));
import { useCartStore } from "@polymech/ecommerce";
const TopNavigation = () => {
const { user, signOut, roles } = useAuth();
const { fetchProfile, profiles } = useProfiles();
const { orgSlug, isOrgContext } = useOrganization();
const location = useLocation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
@ -33,7 +31,7 @@ const TopNavigation = () => {
const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
const cartItemCount = useCartStore((s) => s.itemCount);
const authPath = isOrgContext ? `/org/${orgSlug}/auth` : '/auth';
const authPath = '/auth';
useEffect(() => {
if (user?.id) {
@ -295,12 +293,16 @@ const TopNavigation = () => {
</div>
</div>
<CreationWizardPopup
isOpen={creationWizardOpen}
onClose={() => setCreationWizardOpen(false)}
preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
initialMode={creationWizardMode}
/>
{user && creationWizardOpen && (
<Suspense fallback={null}>
<CreationWizardPopup
isOpen={creationWizardOpen}
onClose={() => setCreationWizardOpen(false)}
preloadedImages={wizardInitialImage ? [wizardInitialImage] : []}
initialMode={creationWizardMode}
/>
</Suspense>
)}
</header>
);
};

View File

@ -1,212 +0,0 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { supabase } from '@/integrations/supabase/client';
import { createPicture } from '@/modules/posts/client-pictures';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { Upload, X } from 'lucide-react';
import MarkdownEditor from '@/components/MarkdownEditor';
import { useOrganization } from '@/contexts/OrganizationContext';
import { uploadImage } from '@/lib/uploadUtils';
const uploadSchema = z.object({
title: z.string().max(100, 'Title must be less than 100 characters').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters').optional(),
file: z.any().refine((file) => file && file.length > 0, 'Please select a file'),
});
type UploadFormData = z.infer<typeof uploadSchema>;
interface UploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUploadSuccess: () => void;
}
const UploadModal = ({ open, onOpenChange, onUploadSuccess }: UploadModalProps) => {
const [uploading, setUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const { user } = useAuth();
const { toast } = useToast();
const { orgSlug, isOrgContext } = useOrganization();
const form = useForm<UploadFormData>({
resolver: zodResolver(uploadSchema),
defaultValues: {
title: '',
description: '',
},
});
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
form.setValue('file', event.target.files);
}
};
const onSubmit = async (data: UploadFormData) => {
if (!user) return;
setUploading(true);
try {
const file = data.file[0];
// Upload file to storage (direct or via proxy)
const { publicUrl } = await uploadImage(file, user.id);
// Save picture metadata via API
await createPicture({
user_id: user.id,
title: data.title?.trim() || null,
description: data.description || null,
image_url: publicUrl
});
toast({
title: "Picture uploaded successfully!",
description: "Your picture has been shared with the community.",
});
form.reset();
setPreviewUrl(null);
onOpenChange(false);
onUploadSuccess();
} catch (error: any) {
toast({
title: "Upload failed",
description: error.message,
variant: "destructive",
});
} finally {
setUploading(false);
}
};
const handleClose = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
form.reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Picture
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormLabel>Picture</FormLabel>
<FormControl>
<div className="space-y-2">
<Input
type="file"
accept="image/*"
onChange={handleFileChange}
className="cursor-pointer"
/>
{previewUrl && (
<div className="relative">
<img
src={previewUrl}
alt="Preview"
className="w-full h-48 object-cover rounded-lg"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70"
onClick={() => {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
form.setValue('file', null);
}}
>
<X className="h-4 w-4 text-white" />
</Button>
</div>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title (Optional)</FormLabel>
<FormControl>
<Input placeholder="Enter a title..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description (Optional)</FormLabel>
<FormControl>
<MarkdownEditor
value={field.value || ''}
onChange={field.onChange}
placeholder="Describe your photo... You can use **markdown** formatting!"
className="min-h-[120px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={uploading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={uploading}
>
{uploading ? 'Uploading...' : 'Upload'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export default UploadModal;

View File

@ -5,7 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import React, { useState, useEffect, useRef } from "react";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
import { useNavigate, useLocation } from "react-router-dom";
import { T, translate } from "@/i18n";
import type { MuxResolution } from "@/types";
@ -462,9 +462,7 @@ const VideoCard = ({
controls
playsInline
className={`w-full ${variant === 'grid' ? "h-full" : ""}`}
layoutProps={{
icons: defaultLayoutIcons
}}
layoutProps={{}}
/>
</React.Suspense>
)}

View File

@ -5,6 +5,7 @@ import { UserPicker } from "./UserPicker";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Trash2, AlertCircle, Check, Loader2, Shield, Globe, Users } from "lucide-react";
import { toast } from "sonner";
import { T, translate } from "@/i18n";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@ -114,7 +115,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
if (next.length === 0) {
try {
await revokeAclPermission(resourceType, mount, { path, userId: ANONYMOUS_USER_ID });
toast.success("Anonymous access revoked");
toast.success(translate("Anonymous access revoked"));
fetchAcl();
} catch (e: any) { toast.error(e.message); }
return;
@ -138,16 +139,16 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
path,
userId: ANONYMOUS_USER_ID,
});
toast.success("Anonymous access revoked");
toast.success(translate("Anonymous access revoked"));
setAnonPerms(['read', 'list']); // reset defaults
} else {
if (anonPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAnon(false); return; }
if (anonPerms.length === 0) { toast.error(translate('Select at least one permission')); setTogglingAnon(false); return; }
await grantAclPermission(resourceType, mount, {
path,
userId: ANONYMOUS_USER_ID,
permissions: anonPerms,
});
toast.success("Anonymous access enabled");
toast.success(translate("Anonymous access enabled"));
}
fetchAcl();
} catch (e: any) {
@ -164,7 +165,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
if (next.length === 0) {
try {
await revokeAclPermission(resourceType, mount, { path, userId: AUTHENTICATED_USER_ID });
toast.success("Authenticated access revoked");
toast.success(translate("Authenticated access revoked"));
fetchAcl();
} catch (e: any) { toast.error(e.message); }
return;
@ -188,16 +189,16 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
path,
userId: AUTHENTICATED_USER_ID,
});
toast.success("Authenticated access revoked");
toast.success(translate("Authenticated access revoked"));
setAuthPerms(['read', 'list']);
} else {
if (authPerms.length === 0) { toast.error('Select at least one permission'); setTogglingAuth(false); return; }
if (authPerms.length === 0) { toast.error(translate('Select at least one permission')); setTogglingAuth(false); return; }
await grantAclPermission(resourceType, mount, {
path,
userId: AUTHENTICATED_USER_ID,
permissions: authPerms,
});
toast.success("Authenticated access enabled");
toast.success(translate("Authenticated access enabled"));
}
fetchAcl();
} catch (e: any) {
@ -217,7 +218,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
userId: selectedUser,
permissions: Array.from(userPerms),
});
toast.success("Access granted");
toast.success(translate("Access granted"));
fetchAcl();
setSelectedUser("");
} catch (e: any) {
@ -228,14 +229,14 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
};
const handleRevoke = async (entry: AclEntry) => {
if (!confirm("Are you sure you want to revoke this permission?")) return;
if (!confirm(translate("Are you sure you want to revoke this permission?"))) return;
try {
await revokeAclPermission(resourceType, mount, {
path: entry.path,
userId: entry.userId,
group: entry.group,
});
toast.success("Access revoked");
toast.success(translate("Access revoked"));
fetchAcl();
} catch (e: any) {
toast.error(e.message);
@ -254,10 +255,10 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
<CardHeader className="px-0 pt-0">
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5" />
Access Control
<T>Access Control</T>
</CardTitle>
<CardDescription>
Manage permissions for <code>{mount}:{path}</code>
<T>Manage permissions for</T> <code>{mount}:{path}</code>
</CardDescription>
</CardHeader>
<CardContent className="px-0 space-y-6">
@ -268,9 +269,9 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-blue-500" />
<div>
<h3 className="text-sm font-medium">Anonymous Access</h3>
<h3 className="text-sm font-medium"><T>Anonymous Access</T></h3>
<p className="text-xs text-muted-foreground">
Allow unauthenticated access on <code>{path}</code>
<T>Allow unauthenticated access on</T> <code>{path}</code>
</p>
</div>
</div>
@ -291,9 +292,9 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-green-500" />
<div>
<h3 className="text-sm font-medium">Authenticated Users</h3>
<h3 className="text-sm font-medium"><T>Authenticated Users</T></h3>
<p className="text-xs text-muted-foreground">
Allow any logged-in user access on <code>{path}</code>
<T>Allow any logged-in user access on</T> <code>{path}</code>
</p>
</div>
</div>
@ -310,14 +311,14 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
{/* Grant Form */}
<div className="space-y-3 border rounded-lg p-4 bg-muted/30">
<h3 className="text-sm font-medium">Grant Access</h3>
<h3 className="text-sm font-medium"><T>Grant Access</T></h3>
<div className="flex gap-2">
<div className="flex-1">
<UserPicker value={selectedUser} onSelect={(id) => setSelectedUser(id)} />
</div>
<Button onClick={handleGrant} disabled={!selectedUser || granting || userPerms.length === 0}>
{granting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4 mr-2" />}
Grant
<T>Grant</T>
</Button>
</div>
<PermissionPicker value={userPerms} onChange={setUserPerms} />
@ -325,14 +326,14 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
{/* ACL List */}
<div className="space-y-2">
<h3 className="text-sm font-medium">Active Permissions (Mount: {mount})</h3>
<h3 className="text-sm font-medium"><T>Active Permissions</T> (Mount: {mount})</h3>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Path</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Permissions</TableHead>
<TableHead><T>Path</T></TableHead>
<TableHead><T>Subject</T></TableHead>
<TableHead><T>Permissions</T></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
@ -346,7 +347,7 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
) : sortedEntries.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No active permissions found.
<T>No active permissions found.</T>
</TableCell>
</TableRow>
) : (
@ -354,13 +355,13 @@ export function AclEditor({ resourceType = 'vfs', mount, path }: AclEditorProps)
<TableRow key={i} className={entry.path === path ? "bg-muted/50" : ""}>
<TableCell className="font-mono text-xs">
{entry.path || '/'}
{entry.path === path && <Badge variant="outline" className="ml-2 text-[10px] h-4">Current</Badge>}
{entry.path === path && <Badge variant="outline" className="ml-2 text-[10px] h-4"><T>Current</T></Badge>}
</TableCell>
<TableCell>
{entry.userId === ANONYMOUS_USER_ID ? (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-blue-500" />
<Badge variant="secondary">Anonymous</Badge>
<Badge variant="secondary"><T>Anonymous</T></Badge>
</div>
) : entry.userId ? (
<div className="flex flex-col">

View File

@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { Shield, Trash2, RefreshCw } from "lucide-react";
import { T, translate } from "@/i18n";
import {
Table,
TableBody,
@ -54,7 +55,7 @@ export const BansManager = ({ session }: { session: any }) => {
const data = await res.json();
setBanList(data);
} catch (err: any) {
toast.error("Failed to fetch ban list", {
toast.error(translate("Failed to fetch ban list"), {
description: err.message
});
} finally {
@ -90,17 +91,17 @@ export const BansManager = ({ session }: { session: any }) => {
const data = await res.json();
if (data.success) {
toast.success("Unbanned successfully", {
toast.success(translate("Unbanned successfully"), {
description: data.message
});
fetchBanList();
} else {
toast.warning("Not found", {
toast.warning(translate("Not found"), {
description: data.message
});
}
} catch (err: any) {
toast.error("Failed to unban", {
toast.error(translate("Failed to unban"), {
description: err.message
});
} finally {
@ -119,18 +120,18 @@ export const BansManager = ({ session }: { session: any }) => {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6" />
<h1 className="text-2xl font-bold">Ban Management</h1>
<h1 className="text-2xl font-bold"><T>Ban Management</T></h1>
</div>
<Button onClick={fetchBanList} disabled={loading} variant="outline">
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Refresh
<T>Refresh</T>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3 mb-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Banned IPs</CardTitle>
<CardTitle className="text-sm font-medium"><T>Banned IPs</T></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banList.bannedIPs.length}</div>
@ -138,7 +139,7 @@ export const BansManager = ({ session }: { session: any }) => {
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Banned Users</CardTitle>
<CardTitle className="text-sm font-medium"><T>Banned Users</T></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banList.bannedUserIds.length}</div>
@ -146,7 +147,7 @@ export const BansManager = ({ session }: { session: any }) => {
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Banned Tokens</CardTitle>
<CardTitle className="text-sm font-medium"><T>Banned Tokens</T></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{banList.bannedTokens.length}</div>
@ -157,7 +158,7 @@ export const BansManager = ({ session }: { session: any }) => {
{totalBans === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No active bans
<T>No active bans</T>
</CardContent>
</Card>
) : (
@ -165,17 +166,17 @@ export const BansManager = ({ session }: { session: any }) => {
{banList.bannedIPs.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Banned IP Addresses</CardTitle>
<CardTitle><T>Banned IP Addresses</T></CardTitle>
<CardDescription>
IP addresses that have been auto-banned for excessive requests
<T>IP addresses that have been auto-banned for excessive requests</T>
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>IP Address</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead><T>IP Address</T></TableHead>
<TableHead className="text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -189,7 +190,7 @@ export const BansManager = ({ session }: { session: any }) => {
onClick={() => setUnbanTarget({ type: 'ip', value: ip })}
>
<Trash2 className="h-4 w-4 mr-1" />
Unban
<T>Unban</T>
</Button>
</TableCell>
</TableRow>
@ -203,17 +204,17 @@ export const BansManager = ({ session }: { session: any }) => {
{banList.bannedUserIds.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Banned Users</CardTitle>
<CardTitle><T>Banned Users</T></CardTitle>
<CardDescription>
User accounts that have been auto-banned for excessive requests
<T>User accounts that have been auto-banned for excessive requests</T>
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead><T>User ID</T></TableHead>
<TableHead className="text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -227,7 +228,7 @@ export const BansManager = ({ session }: { session: any }) => {
onClick={() => setUnbanTarget({ type: 'user', value: userId })}
>
<Trash2 className="h-4 w-4 mr-1" />
Unban
<T>Unban</T>
</Button>
</TableCell>
</TableRow>
@ -241,16 +242,16 @@ export const BansManager = ({ session }: { session: any }) => {
{banList.bannedTokens.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Banned Tokens</CardTitle>
<CardTitle><T>Banned Tokens</T></CardTitle>
<CardDescription>
Authentication tokens that have been auto-banned (cannot be unbanned via UI)
<T>Authentication tokens that have been auto-banned (cannot be unbanned via UI)</T>
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Token (truncated)</TableHead>
<TableHead><T>Token (truncated)</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -272,7 +273,7 @@ export const BansManager = ({ session }: { session: any }) => {
<AlertDialog open={!!unbanTarget} onOpenChange={(open) => !open && setUnbanTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Unban</AlertDialogTitle>
<AlertDialogTitle><T>Confirm Unban</T></AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to unban this {unbanTarget?.type}?
<div className="mt-2 p-2 bg-muted rounded font-mono text-sm">
@ -281,8 +282,8 @@ export const BansManager = ({ session }: { session: any }) => {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleUnban}>Unban</AlertDialogAction>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction onClick={handleUnban}><T>Unban</T></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -3,6 +3,7 @@ import { FileBrowserWidget } from "@/modules/storage";
import { AclEditor } from "@/components/admin/AclEditor";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { T } from "@/i18n";
export default function StorageManager() {
// Default to 'root' mount, but we could allow selecting mounts if there are multiple.
@ -20,7 +21,7 @@ export default function StorageManager() {
{/* Left Pane: File Browser */}
<Card className="flex-1 flex flex-col overflow-hidden">
<div className="p-4 border-b bg-muted/20">
<h2 className="font-semibold">File Browser</h2>
<h2 className="font-semibold"><T>File Browser</T></h2>
</div>
<div className="flex-1 overflow-auto bg-background">
<FileBrowserWidget
@ -48,11 +49,11 @@ export default function StorageManager() {
{/* Right Pane: ACL Editor */}
<Card className="w-[400px] flex flex-col border-l shadow-lg">
<div className="p-4 border-b bg-muted/20">
<h2 className="font-semibold">Permissions</h2>
<h2 className="font-semibold"><T>Permissions</T></h2>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="mb-4">
<div className="text-sm font-medium text-muted-foreground">Selected Path</div>
<div className="text-sm font-medium text-muted-foreground"><T>Selected Path</T></div>
<div className="font-mono text-sm break-all bg-muted p-2 rounded mt-1">
{mount}:{targetPath}
</div>

View File

@ -69,17 +69,17 @@ const UserManager = () => {
<div className="flex justify-end mb-4">
<Button onClick={() => setCreateUserOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create User
<T>Create User</T>
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Username</TableHead>
<TableHead>Joined Date</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead><T>User</T></TableHead>
<TableHead><T>Username</T></TableHead>
<TableHead><T>Joined Date</T></TableHead>
<TableHead className="text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -115,12 +115,12 @@ const UserManager = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setEditingUser(user)}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditingUser(user)}><T>Edit</T></DropdownMenuItem>
<DropdownMenuItem
className="text-red-500"
onClick={() => setDeletingUser(user)}
>
Delete
<T>Delete</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { T, translate } from "@/i18n";
import {
Table,
TableBody,
@ -48,7 +49,7 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
const data = await res.json();
setStats(data);
} catch (err: any) {
toast.error("Failed to fetch violation stats", {
toast.error(translate("Failed to fetch violation stats"), {
description: err.message
});
} finally {
@ -88,19 +89,19 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<AlertTriangle className="h-6 w-6" />
<h1 className="text-2xl font-bold">Violation Monitor</h1>
<h1 className="text-2xl font-bold"><T>Violation Monitor</T></h1>
</div>
<Button onClick={fetchViolationStats} disabled={loading} variant="outline">
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Refresh
<T>Refresh</T>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 mb-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Active Violations</CardTitle>
<CardDescription>Currently tracked violation records</CardDescription>
<CardTitle className="text-sm font-medium"><T>Active Violations</T></CardTitle>
<CardDescription><T>Currently tracked violation records</T></CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalViolations}</div>
@ -108,13 +109,13 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Auto-Refresh</CardTitle>
<CardDescription>Updates every 5 seconds</CardDescription>
<CardTitle className="text-sm font-medium"><T>Auto-Refresh</T></CardTitle>
<CardDescription><T>Updates every 5 seconds</T></CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm text-muted-foreground">Live monitoring</span>
<span className="text-sm text-muted-foreground"><T>Live monitoring</T></span>
</div>
</CardContent>
</Card>
@ -123,26 +124,26 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
{stats.totalViolations === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No active violations
<T>No active violations</T>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Violation Records</CardTitle>
<CardTitle><T>Violation Records</T></CardTitle>
<CardDescription>
Entities approaching the ban threshold (5 violations within the configured window)
<T>Entities approaching the ban threshold (5 violations within the configured window)</T>
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Identifier</TableHead>
<TableHead>Count</TableHead>
<TableHead>First Violation</TableHead>
<TableHead>Last Violation</TableHead>
<TableHead><T>Type</T></TableHead>
<TableHead><T>Identifier</T></TableHead>
<TableHead><T>Count</T></TableHead>
<TableHead><T>First Violation</T></TableHead>
<TableHead><T>Last Violation</T></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -180,18 +181,17 @@ export const ViolationsMonitor = ({ session }: { session: any }) => {
<Card className="mt-6">
<CardHeader>
<CardTitle>About Violations</CardTitle>
<CardTitle><T>About Violations</T></CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
<strong>Violation Tracking:</strong> The system tracks rate limit violations for IPs and authenticated users.
<strong><T>Violation Tracking:</T></strong> <T>The system tracks rate limit violations for IPs and authenticated users.</T>
</p>
<p>
<strong>Auto-Ban Threshold:</strong> When an entity reaches 5 violations within the configured time window,
they are automatically banned and moved to the ban list.
<strong><T>Auto-Ban Threshold:</T></strong> <T>When an entity reaches 5 violations within the configured time window, they are automatically banned and moved to the ban list.</T>
</p>
<p>
<strong>Cleanup:</strong> Violation records are automatically cleaned up after the time window expires.
<strong><T>Cleanup:</T></strong> <T>Violation records are automatically cleaned up after the time window expires.</T>
</p>
</CardContent>
</Card>

View File

@ -24,6 +24,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Loader2, Zap, Globe, Brain, Server, Settings, Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProviderSelectorProps {
@ -109,7 +110,7 @@ export const ProviderSelector: React.FC<ProviderSelectorProps> = ({
const handleProviderChange = (newProvider: string) => {
onProviderChange(newProvider);
// Reset model when provider changes
const models = getCurrentModels(newProvider);
if (models.length > 0) {
@ -236,8 +237,8 @@ export const ProviderSelector: React.FC<ProviderSelectorProps> = ({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
<PopoverContent
className="w-full p-0"
align="start"
side="bottom"
sideOffset={5}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, $getSelection, $isRangeSelection, $createParagraphNode, $insertNodes, $getRoot, $createTextNode, createCommand, LexicalCommand } from 'lexical';
import { COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, $getSelection, $getRoot, createCommand, LexicalCommand } from 'lexical';
import { mergeRegister } from '@lexical/utils';
import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@mdxeditor/editor';
import { AIPromptPopup } from './AIPromptPopup';

View File

@ -149,7 +149,7 @@ export default function MDXEditorInternal({
onChange={onChange}
plugins={allPlugins}
placeholder={placeholder}
contentEditableClassName="prose prose-sm max-w-none dark:prose-invert"
contentEditableClassName={`prose prose-sm max-w-none ${isDarkMode ? 'prose-invert' : ''}`}
/>
);
}

View File

@ -1,12 +1,22 @@
import React, { useState, useEffect } from 'react';
import { translateText, fetchGlossaries, createGlossary, deleteGlossary, TargetLanguageCodeSchema, Glossary } from '@/modules/i18n/client-i18n';
import React, { useState, useEffect, useRef } from 'react';
import {
translateText, fetchGlossaries, createGlossary, deleteGlossary,
fetchGlossaryTerms, updateGlossaryTerms,
TargetLanguageCodeSchema, Glossary,
WidgetTranslation, fetchWidgetTranslations, fetchTranslationGaps,
} from '@/modules/i18n/client-i18n';
import { WidgetTranslationPanel, type WidgetTranslationPanelHandle } from '@/modules/i18n/WidgetTranslationPanel';
import { getRequestedTerms } from '@/i18n';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Trash2, Plus } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Loader2, Trash2, Plus, Search, Pencil, Save, X, Languages, Import, ChevronDown, ChevronRight, FileUp, Download, BookPlus } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { useAppConfig } from '@/hooks/useSystemInfo';
// Safely extract options from ZodUnion of ZodEnums
const TARGET_LANGS = [
@ -15,8 +25,11 @@ const TARGET_LANGS = [
].sort();
export default function I18nPlayground() {
const appConfig = useAppConfig();
const srcLangDefault = appConfig?.i18n?.source_language || 'en';
// Translation State
const [srcLang, setSrcLang] = useState('en');
const [srcLang, setSrcLang] = useState(srcLangDefault);
const [dstLang, setDstLang] = useState('fr');
const [text, setText] = useState('');
const [translation, setTranslation] = useState('');
@ -29,10 +42,30 @@ export default function I18nPlayground() {
// New Glossary State
const [newGlossaryName, setNewGlossaryName] = useState('');
const [newGlossarySrc, setNewGlossarySrc] = useState('en');
const [newGlossarySrc, setNewGlossarySrc] = useState(srcLangDefault);
const [newGlossaryDst, setNewGlossaryDst] = useState('fr');
const [newGlossaryEntries, setNewGlossaryEntries] = useState(''); // CSV format: term,translation
// Glossary Term Editor State
const [expandedGlossaryId, setExpandedGlossaryId] = useState<string | null>(null);
const [glossaryTerms, setGlossaryTerms] = useState<Record<string, string>>({});
const [glossaryTermsOriginal, setGlossaryTermsOriginal] = useState<Record<string, string>>({});
const [glossaryTermsLoading, setGlossaryTermsLoading] = useState(false);
const [glossaryTermsSaving, setGlossaryTermsSaving] = useState(false);
const [newTermKey, setNewTermKey] = useState('');
const [newTermValue, setNewTermValue] = useState('');
// Widget Translation State (kept for import-from-i18n feature)
const [wtList, setWtList] = useState<WidgetTranslation[]>([]);
const [wtImportTargetLang, setWtImportTargetLang] = useState('de');
const panelRef = useRef<WidgetTranslationPanelHandle>(null);
// Missing Translations State
const [mtEntityType, setMtEntityType] = useState('category');
const [mtTargetLang, setMtTargetLang] = useState('de');
const [mtLoading, setMtLoading] = useState(false);
const [mtIncludeOutdated, setMtIncludeOutdated] = useState(true);
useEffect(() => {
loadGlossaries();
}, []);
@ -71,7 +104,7 @@ export default function I18nPlayground() {
const parts = line.split(',');
if (parts.length >= 2) {
const term = parts[0].trim();
const trans = parts.slice(1).join(',').trim(); // Handle commas in translation? Simple CSV logic.
const trans = parts.slice(1).join(',').trim();
if (term && trans) entries[term] = trans;
}
});
@ -103,6 +136,178 @@ export default function I18nPlayground() {
}
};
// --- Glossary Term Editor Handlers ---
const toggleGlossaryExpand = async (glossaryId: string) => {
if (expandedGlossaryId === glossaryId) {
setExpandedGlossaryId(null);
return;
}
setExpandedGlossaryId(glossaryId);
setGlossaryTermsLoading(true);
try {
const terms = await fetchGlossaryTerms(glossaryId);
setGlossaryTerms({ ...terms });
setGlossaryTermsOriginal({ ...terms });
} catch (e: any) {
toast.error(`Failed to load terms: ${e.message}`);
} finally {
setGlossaryTermsLoading(false);
}
};
const handleTermChange = (oldKey: string, field: 'term' | 'translation', value: string) => {
setGlossaryTerms(prev => {
const updated = { ...prev };
if (field === 'term') {
const oldVal = updated[oldKey];
delete updated[oldKey];
updated[value] = oldVal;
} else {
updated[oldKey] = value;
}
return updated;
});
};
const handleAddTerm = () => {
if (!newTermKey.trim()) return;
setGlossaryTerms(prev => ({ ...prev, [newTermKey.trim()]: newTermValue.trim() }));
setNewTermKey('');
setNewTermValue('');
};
const handleDeleteTerm = (key: string) => {
setGlossaryTerms(prev => {
const updated = { ...prev };
delete updated[key];
return updated;
});
};
const glossaryTermsDirty = (() => {
const keys = Object.keys(glossaryTerms);
const origKeys = Object.keys(glossaryTermsOriginal);
if (keys.length !== origKeys.length) return true;
return keys.some(k => glossaryTerms[k] !== glossaryTermsOriginal[k]);
})();
const handleSaveGlossaryTerms = async () => {
if (!expandedGlossaryId) return;
setGlossaryTermsSaving(true);
try {
const res = await updateGlossaryTerms(expandedGlossaryId, glossaryTerms);
toast.success(`Saved ${res.entry_count} terms to DeepL + DB`);
setGlossaryTermsOriginal({ ...glossaryTerms });
loadGlossaries(); // refresh entry counts
} catch (e: any) {
toast.error(`Save failed: ${e.message}`);
} finally {
setGlossaryTermsSaving(false);
}
};
// --- Widget Translations Handlers ---
const handleWtImportFromI18n = async () => {
const allTerms = getRequestedTerms() as { [lang: string]: Record<string, string> };
// 1. Fetch existing DB translations for this target lang
let dbItems: WidgetTranslation[] = [];
try {
dbItems = await fetchWidgetTranslations({ targetLang: wtImportTargetLang });
} catch (e) {
console.error('Failed to fetch existing translations', e);
}
// 2. Build new entries from i18n localStorage
// Use source language config as key source since it holds ALL keys; look up translations from target lang
const sourceTerms = allTerms[srcLangDefault] || {};
const targetTerms = allTerms[wtImportTargetLang] || {};
const existingKeys = new Set(dbItems.map(wt => wt.prop_path));
const newEntries: WidgetTranslation[] = Object.entries(sourceTerms)
.filter(([key]) => !existingKeys.has(key))
.map(([key, _sourceText], i) => {
const translated = targetTerms[key];
const hasTranslation = translated && translated !== key;
return {
id: `local-${Date.now()}-${i}`,
entity_type: 'system',
entity_id: null,
widget_id: null,
prop_path: key,
source_lang: srcLangDefault,
target_lang: wtImportTargetLang,
source_text: key,
translated_text: hasTranslation ? translated : '',
status: hasTranslation ? 'machine' : 'draft',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as WidgetTranslation;
});
// 3. Merge: DB items + new i18n items
const merged = [...dbItems, ...newEntries];
panelRef.current?.replaceList(merged);
toast.success(`Showing ${dbItems.length} existing + ${newEntries.length} new ${wtImportTargetLang} terms.`);
};
const handleFetchMissing = async () => {
setMtLoading(true);
try {
const missing = await fetchTranslationGaps(mtEntityType, mtTargetLang, undefined, mtIncludeOutdated ? 'all' : 'missing');
if (missing.length === 0) {
toast.info(`No missing ${mtEntityType} translations for ${mtTargetLang}`);
return;
}
// Add synthetic IDs so the panel can track them
const withIds = missing.map((item, i) => ({
...item,
id: `missing-${Date.now()}-${i}`,
})) as WidgetTranslation[];
panelRef.current?.replaceList(withIds);
toast.success(`Found ${withIds.length} missing ${mtEntityType} translations for ${mtTargetLang}`);
} catch (e: any) {
toast.error(`Failed: ${e.message}`);
} finally {
setMtLoading(false);
}
};
const handleAddToGlossary = async (item: WidgetTranslation) => {
if (!item.source_text || !item.translated_text) return;
const srcLangUpper = (item.source_lang || 'en').toUpperCase();
const tgtLangUpper = (item.target_lang || 'en').toUpperCase();
try {
// Find matching glossary
let glossary = glossaries.find(
g => g.source_lang.toUpperCase() === srcLangUpper && g.target_lang.toUpperCase() === tgtLangUpper
);
if (!glossary) {
// Create a new one
const name = `Auto ${srcLangUpper}${tgtLangUpper}`;
await createGlossary(name, srcLangUpper, tgtLangUpper, {
[item.source_text]: item.translated_text,
});
await loadGlossaries();
toast.success(`Created glossary "${name}" with 1 entry`);
return;
}
// Fetch existing terms, merge, update
const existingTerms = await fetchGlossaryTerms(glossary.glossary_id);
existingTerms[item.source_text] = item.translated_text;
await updateGlossaryTerms(glossary.glossary_id, existingTerms);
await loadGlossaries();
toast.success(`Added "${item.source_text}" → "${item.translated_text}" to ${glossary.name}`);
} catch (e: any) {
toast.error(`Failed to add to glossary: ${e.message}`);
}
};
return (
<div className="container mx-auto p-6 space-y-8">
<h1 className="text-3xl font-bold">i18n / DeepL Playground</h1>
@ -143,10 +348,13 @@ export default function I18nPlayground() {
<SelectContent>
<SelectItem value="none">None</SelectItem>
{glossaries
.filter(g => g.source_lang === srcLang && g.target_lang === dstLang)
.filter(g =>
(g.source_lang === srcLang && g.target_lang === dstLang) ||
(g.source_lang === dstLang && g.target_lang === srcLang)
)
.map(g => (
<SelectItem key={g.glossary_id} value={g.glossary_id}>
{g.name} ({g.entry_count} entries)
{g.name} ({g.source_lang}{g.target_lang}, {g.entry_count} entries)
</SelectItem>
))}
</SelectContent>
@ -181,26 +389,121 @@ export default function I18nPlayground() {
</CardHeader>
<CardContent className="space-y-6">
{/* List */}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{loadingGlossaries ? (
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
) : glossaries.length === 0 ? (
<p className="text-muted-foreground text-center">No glossaries found.</p>
) : (
glossaries.map(g => (
<div key={g.glossary_id} className="flex items-center justify-between p-2 border rounded-md">
<div>
<p className="font-medium">{g.name}</p>
<p className="text-xs text-muted-foreground">{g.source_lang} -&gt; {g.target_lang} {g.entry_count} entries</p>
glossaries.map(g => {
const isExpanded = expandedGlossaryId === g.glossary_id;
return (
<div key={g.glossary_id} className="border rounded-md overflow-hidden">
<div className="flex items-center justify-between p-2 cursor-pointer hover:bg-muted/30" onClick={() => toggleGlossaryExpand(g.glossary_id)}>
<div className="flex items-center gap-2">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<div>
<p className="font-medium">{g.name}</p>
<p className="text-xs text-muted-foreground">{g.source_lang} -&gt; {g.target_lang} {g.entry_count} entries</p>
</div>
</div>
<Button variant="ghost" size="icon" onClick={(e) => { e.stopPropagation(); handleDeleteGlossary(g.glossary_id); }}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
{isExpanded && (
<div className="border-t p-3 space-y-2 bg-muted/10">
{glossaryTermsLoading ? (
<div className="flex justify-center py-4"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : (
<>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="text-left pb-1 font-medium">Term ({g.source_lang})</th>
<th className="text-left pb-1 font-medium">Translation ({g.target_lang})</th>
<th className="w-8"></th>
</tr>
</thead>
<tbody className="divide-y">
{Object.entries(glossaryTerms).map(([term, translation]) => (
<tr key={term} className="group">
<td className="py-1 pr-1">
<Input
value={term}
onChange={e => handleTermChange(term, 'term', e.target.value)}
className="h-7 text-xs"
/>
</td>
<td className="py-1 pr-1">
<Input
value={translation}
onChange={e => handleTermChange(term, 'translation', e.target.value)}
className="h-7 text-xs"
/>
</td>
<td className="py-1">
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100" onClick={() => handleDeleteTerm(term)}>
<X className="h-3 w-3" />
</Button>
</td>
</tr>
))}
{/* Add new term row */}
<tr className="border-t border-dashed">
<td className="py-1 pr-1">
<Input
placeholder="New term..."
value={newTermKey}
onChange={e => setNewTermKey(e.target.value)}
className="h-7 text-xs"
onKeyDown={e => e.key === 'Enter' && handleAddTerm()}
/>
</td>
<td className="py-1 pr-1">
<Input
placeholder="Translation..."
value={newTermValue}
onChange={e => setNewTermValue(e.target.value)}
className="h-7 text-xs"
onKeyDown={e => e.key === 'Enter' && handleAddTerm()}
/>
</td>
<td className="py-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleAddTerm} disabled={!newTermKey.trim()}>
<Plus className="h-3 w-3" />
</Button>
</td>
</tr>
</tbody>
</table>
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{Object.keys(glossaryTerms).length} terms
{glossaryTermsDirty && <span className="ml-1 text-orange-500"> modified</span>}
</span>
<Button
size="sm"
onClick={handleSaveGlossaryTerms}
disabled={!glossaryTermsDirty || glossaryTermsSaving}
>
{glossaryTermsSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
Save to DeepL
</Button>
</div>
</>
)}
</div>
)}
</div>
<Button variant="ghost" size="icon" onClick={() => handleDeleteGlossary(g.glossary_id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))
);
})
)}
</div>
<div className="border-t pt-4 space-y-4">
<h3 className="text-lg font-medium">Create New Glossary</h3>
<div className="grid grid-cols-2 gap-2">
@ -220,9 +523,123 @@ export default function I18nPlayground() {
<Plus className="mr-2 h-4 w-4" /> Create Glossary
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</CardContent >
</Card >
</div >
{/* Widget Translations Section — segmented by task */}
<Card>
<CardHeader>
<CardTitle>Widget Translations</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Tabs defaultValue="db-search" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="db-search" className="text-xs">
<Search className="h-3.5 w-3.5 mr-1" /> DB Search
</TabsTrigger>
<TabsTrigger value="import-i18n" className="text-xs">
<Import className="h-3.5 w-3.5 mr-1" /> Import i18n
</TabsTrigger>
<TabsTrigger value="fetch-missing" className="text-xs">
<Download className="h-3.5 w-3.5 mr-1" /> Fetch Missing
</TabsTrigger>
</TabsList>
{/* Tab 1: DB Search — uses panel's built-in filter bar */}
<TabsContent value="db-search" className="mt-3">
<p className="text-xs text-muted-foreground mb-2">
Search existing widget translations in the database using the filter bar below.
</p>
</TabsContent>
{/* Tab 2: Import from browser i18n localStorage */}
<TabsContent value="import-i18n" className="mt-3">
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
<span className="text-sm text-muted-foreground shrink-0">Target:</span>
<Select value={wtImportTargetLang} onValueChange={setWtImportTargetLang}>
<SelectTrigger className="h-8 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={handleWtImportFromI18n}
>
<Import className="h-4 w-4 mr-1" />
Import from i18n
</Button>
<span className="text-xs text-muted-foreground">
Load terms from browser i18n localStorage and merge with DB entries.
</span>
</div>
</TabsContent>
{/* Tab 3: Fetch Missing translations from server */}
<TabsContent value="fetch-missing" className="mt-3">
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
<span className="text-sm text-muted-foreground shrink-0">Type:</span>
<Select value={mtEntityType} onValueChange={setMtEntityType}>
<SelectTrigger className="h-8 w-[110px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="category">Category</SelectItem>
<SelectItem value="page">Page</SelectItem>
<SelectItem value="type">Type</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground shrink-0"></span>
<Select value={mtTargetLang} onValueChange={setMtTargetLang}>
<SelectTrigger className="h-8 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={handleFetchMissing}
disabled={mtLoading}
>
{mtLoading ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Download className="h-4 w-4 mr-1" />}
Fetch Missing
</Button>
<label className="flex items-center gap-1.5 shrink-0 cursor-pointer">
<Checkbox
checked={mtIncludeOutdated}
onCheckedChange={(v) => setMtIncludeOutdated(!!v)}
/>
<span className="text-xs text-muted-foreground">Include outdated</span>
</label>
</div>
</TabsContent>
</Tabs>
{/* Shared Panel — always visible below the tabs */}
<WidgetTranslationPanel
ref={panelRef}
showFilterBar
autoLoad
glossaries={glossaries}
defaultSourceLang="de"
defaultTargetLang="en"
onTranslationsChange={setWtList}
onAddToGlossary={handleAddToGlossary}
/>
</CardContent>
</Card>
</div >
);
}

View File

@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Type as TypeIcon, Hash, ToggleLeft, Trash2, Lock, Plus, Import, Download, ChevronDown, Copy, FileJson } from 'lucide-react';
import { toast } from "sonner";
import { T, translate } from '@/i18n';
export interface VariableElement {
id: string;
@ -56,11 +57,11 @@ const CanvasElement = ({
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-medium truncate">{element.key || 'New Variable'}</span>
<span className="text-sm font-medium truncate">{element.key || translate('New Variable')}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground bg-muted px-1 rounded">{element.type}</span>
<span className="text-xs text-muted-foreground truncate opacity-70">
{element.type === 'secret' ? '••••••••' : (element.value || '(empty)')}
{element.type === 'secret' ? '••••••••' : (element.value || translate('(empty)'))}
</span>
</div>
</div>
@ -80,13 +81,13 @@ const CanvasElement = ({
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Delete Variable?</AlertDialogTitle>
<AlertDialogTitle><T>Delete Variable?</T></AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete variable "{element.key}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel><T>Cancel</T></AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.stopPropagation();
@ -95,7 +96,7 @@ const CanvasElement = ({
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
<T>Delete</T>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -305,10 +306,10 @@ const VariableBuilderContent = ({
setElements(prev => [...prev, ...newElements]);
setImportJson('');
setIsImportOpen(false);
toast.success(`Imported ${newElements.length} variables`);
toast.success(translate(`Imported ${newElements.length} variables`));
} catch (error) {
console.error("Import failed", error);
toast.error("Invalid JSON format");
toast.error(translate("Invalid JSON format"));
}
};
@ -326,7 +327,7 @@ const VariableBuilderContent = ({
}
});
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
toast.success("Copied to clipboard");
toast.success(translate("Copied to clipboard"));
};
const handleExportFile = () => {
@ -351,7 +352,7 @@ const VariableBuilderContent = ({
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Download started");
toast.success(translate("Download started"));
};
return (
@ -359,7 +360,7 @@ const VariableBuilderContent = ({
{/* Top Toolbar: Palette & Search & Actions */}
<div className="flex flex-col border-b bg-muted/20">
<div className="flex items-start justify-between p-3 border-b border-border/50 gap-4">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pt-1.5">Variables</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pt-1.5"><T>Variables</T></span>
<div className="flex items-center gap-2 shrink-0">
<Input
@ -378,9 +379,9 @@ const VariableBuilderContent = ({
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Variables (JSON)</DialogTitle>
<DialogTitle><T>Import Variables (JSON)</T></DialogTitle>
<DialogDescription>
Paste a JSON object {`{"KEY": "VALUE"}`} to import variables.
<T>Paste a JSON object to import variables.</T>
</DialogDescription>
</DialogHeader>
<Textarea
@ -390,8 +391,8 @@ const VariableBuilderContent = ({
placeholder={`{\n "API_KEY": "12345",\n "ENABLE_FEATURE": "true"\n}`}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsImportOpen(false)}>Cancel</Button>
<Button onClick={handleImport}>Import Variables</Button>
<Button variant="outline" onClick={() => setIsImportOpen(false)}><T>Cancel</T></Button>
<Button onClick={handleImport}><T>Import Variables</T></Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -404,10 +405,10 @@ const VariableBuilderContent = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExportCopy}>
<Copy className="mr-2 h-4 w-4" /> Copy to Clipboard
<Copy className="mr-2 h-4 w-4" /> <T>Copy to Clipboard</T>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportFile}>
<FileJson className="mr-2 h-4 w-4" /> Download JSON
<FileJson className="mr-2 h-4 w-4" /> <T>Download JSON</T>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -416,17 +417,17 @@ const VariableBuilderContent = ({
<div className="w-[1px] h-4 bg-border mx-1" />
<Button size="sm" onClick={handleSave} disabled={isSaving || duplicateKeys.size > 0} className="h-7 text-xs px-3">
{isSaving ? 'Saving...' : 'Save Changes'}
{isSaving ? translate('Saving...') : translate('Save Changes')}
</Button>
</div>
</div>
<div className="flex items-center gap-2 p-2 px-3 overflow-x-auto no-scrollbar bg-background/50">
<span className="text-xs font-semibold text-muted-foreground mr-2 uppercase tracking-wider">Add:</span>
<PaletteItem type="string" label="String" onClick={() => onAddVariable('string')} />
<PaletteItem type="number" label="Number" onClick={() => onAddVariable('number')} />
<PaletteItem type="boolean" label="Boolean" onClick={() => onAddVariable('boolean')} />
<PaletteItem type="secret" label="Secret" onClick={() => onAddVariable('secret')} />
<span className="text-xs font-semibold text-muted-foreground mr-2 uppercase tracking-wider"><T>Add:</T></span>
<PaletteItem type="string" label={translate("String")} onClick={() => onAddVariable('string')} />
<PaletteItem type="number" label={translate("Number")} onClick={() => onAddVariable('number')} />
<PaletteItem type="boolean" label={translate("Boolean")} onClick={() => onAddVariable('boolean')} />
<PaletteItem type="secret" label={translate("Secret")} onClick={() => onAddVariable('secret')} />
</div>
</div>
@ -436,13 +437,13 @@ const VariableBuilderContent = ({
<div className="flex-1 p-4 overflow-y-auto bg-muted/5 relative">
{elements.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground border-2 border-dashed rounded-lg opacity-50 m-4 min-h-[200px]">
<p className="text-sm">Click types in the top bar to add variables</p>
<p className="text-sm"><T>Click types in the top bar to add variables</T></p>
</div>
) : (
<div className="space-y-2">
{duplicateKeys.size > 0 && (
<div className="bg-destructive/10 text-destructive text-sm p-3 rounded mb-4 border border-destructive/20">
Duplicate keys detected: {Array.from(duplicateKeys).join(', ')}
<T>Duplicate keys detected</T>: {Array.from(duplicateKeys).join(', ')}
</div>
)}
{filteredElements.map(el => (
@ -468,13 +469,13 @@ const VariableBuilderContent = ({
{/* Configuration Sidebar - Right Side */}
<div className="w-[300px] border-l flex flex-col bg-background shrink-0">
<div className="p-3 border-b bg-muted/20">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Properties</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider"><T>Properties</T></span>
</div>
<div className="flex-1 p-4 overflow-y-auto">
{selectedElement ? (
<div className="space-y-5">
<div className="space-y-2">
<Label className="text-xs">Key Name</Label>
<Label className="text-xs"><T>Key Name</T></Label>
<Input
value={selectedElement.key}
onChange={e => updateSelectedElement({ key: e.target.value })}
@ -484,7 +485,7 @@ const VariableBuilderContent = ({
</div>
<div className="space-y-2">
<Label className="text-xs">Value</Label>
<Label className="text-xs"><T>Value</T></Label>
{selectedElement.type === 'boolean' ? (
<div className="flex items-center gap-2 border p-2 rounded bg-background">
<input
@ -513,7 +514,7 @@ const VariableBuilderContent = ({
</div>
<div className="pt-4 border-t space-y-2">
<Label className="text-xs">Type</Label>
<Label className="text-xs"><T>Type</T></Label>
<div className="grid grid-cols-2 gap-2">
{['string', 'number', 'boolean', 'secret'].map(t => {
const Icon = getIconForType(t);
@ -536,7 +537,7 @@ const VariableBuilderContent = ({
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground p-4 text-center opacity-60">
<TypeIcon className="h-8 w-8 mb-2 stroke-1" />
<p className="text-sm">Select a variable to configure</p>
<p className="text-sm"><T>Select a variable to configure</T></p>
</div>
)}
</div>

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { VariableBuilder } from './VariableBuilder';
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { translate } from '@/i18n';
const STORAGE_KEY = 'variables-editor-playground';
@ -36,7 +37,7 @@ export const VariablesEditor: React.FC<VariablesEditorProps> = ({
setVariables(data || {});
} catch (error) {
console.error("Failed to fetch variables", error);
toast.error("Failed to load variables");
toast.error(translate("Failed to load variables"));
} finally {
setLoading(false);
}
@ -51,10 +52,10 @@ export const VariablesEditor: React.FC<VariablesEditorProps> = ({
try {
await onSave(newVariables);
setVariables(newVariables);
toast.success("Variables saved successfully");
toast.success(translate("Variables saved successfully"));
} catch (error) {
console.error("Failed to save variables", error);
toast.error("Failed to save variables");
toast.error(translate("Failed to save variables"));
} finally {
setSaving(false);
}

View File

@ -7,13 +7,15 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { fetchCategories, createCategory, updateCategory, deleteCategory, Category } from "@/modules/categories/client-categories";
import { toast } from "sonner";
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2, Hash } from "lucide-react";
import { Plus, Edit2, Trash2, FolderTree, Link as LinkIcon, Check, X, Loader2, Hash, Languages } from "lucide-react";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { T } from "@/i18n";
import { T, translate } from "@/i18n";
import { VariablesEditor } from '@/components/variables/VariablesEditor';
import { updatePageMeta } from "@/modules/pages/client-pages";
import { fetchTypes } from "@/modules/types/client-types";
import { CategoryTranslationDialog } from "@/modules/i18n/CategoryTranslationDialog";
import { useAppConfig } from '@/hooks/useSystemInfo';
interface CategoryManagerProps {
isOpen: boolean;
@ -45,6 +47,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const [isCreating, setIsCreating] = useState(false);
const [creationParentId, setCreationParentId] = useState<string | null>(null);
const [showVariablesEditor, setShowVariablesEditor] = useState(false);
const [showTranslationDialog, setShowTranslationDialog] = useState(false);
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language;
// Initial linked category from page meta
const getLinkedCategoryIds = (): string[] => {
@ -60,7 +65,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const { data: categories = [], isLoading: loading } = useQuery({
queryKey: ['categories'],
queryFn: async () => {
const data = await fetchCategories({ includeChildren: true });
const data = await fetchCategories({ includeChildren: true, sourceLang: srcLang });
// Filter by type if specified
let filtered = filterByType
? data.filter(cat => (cat as any).meta?.type === filterByType)
@ -100,7 +105,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const handleSave = async () => {
if (!editingCategory || !editingCategory.name || !editingCategory.slug) {
toast.error("Name and Slug are required");
toast.error(translate("Name and Slug are required"));
return;
}
@ -123,16 +128,16 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
}
await createCategory(categoryData);
toast.success("Category created");
toast.success(translate("Category created"));
} else if (editingCategory.id) {
await updateCategory(editingCategory.id, editingCategory);
toast.success("Category updated");
toast.success(translate("Category updated"));
}
setEditingCategory(null);
queryClient.invalidateQueries({ queryKey: ['categories'] });
} catch (error) {
console.error(error);
toast.error(isCreating ? "Failed to create category" : "Failed to update category");
toast.error(isCreating ? translate("Failed to create category") : translate("Failed to update category"));
} finally {
setActionLoading(false);
}
@ -140,17 +145,17 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this category?")) return;
if (!confirm(translate("Are you sure you want to delete this category?"))) return;
setActionLoading(true);
try {
await deleteCategory(id);
toast.success("Category deleted");
toast.success(translate("Category deleted"));
queryClient.invalidateQueries({ queryKey: ['categories'] });
if (selectedCategoryId === id) setSelectedCategoryId(null);
} catch (error) {
console.error(error);
toast.error("Failed to delete category");
toast.error(translate("Failed to delete category"));
} finally {
setActionLoading(false);
}
@ -209,10 +214,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
}
toast.success("Added to category");
toast.success(translate("Added to category"));
} catch (error) {
console.error(error);
toast.error("Failed to link");
toast.error(translate("Failed to link"));
} finally {
setActionLoading(false);
}
@ -240,10 +245,10 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
await updatePageMeta(currentPageId, { categoryIds: newIds, categoryId: null });
}
toast.success("Removed from category");
toast.success(translate("Removed from category"));
} catch (error) {
console.error(error);
toast.error("Failed to unlink");
toast.error(translate("Failed to unlink"));
} finally {
setActionLoading(false);
}
@ -336,7 +341,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
<DialogHeader>
<DialogTitle><T>Select Categories</T></DialogTitle>
<DialogDescription>
Choose one or more categories for your page.
<T>Choose one or more categories for your page.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 border rounded-md p-2">
@ -345,13 +350,13 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
) : (
<div className="space-y-1">
{categories.map(cat => renderPickerItem(cat))}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8">No categories found.</div>}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8"><T>No categories found.</T></div>}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={() => { onPick?.(pickerSelectedIds); onClose(); }}>Done</Button>
<Button variant="outline" onClick={onClose}><T>Cancel</T></Button>
<Button onClick={() => { onPick?.(pickerSelectedIds); onClose(); }}><T>Done</T></Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -364,7 +369,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
<DialogHeader>
<DialogTitle><T>Category Manager</T></DialogTitle>
<DialogDescription>
Manage categories and organize your content structure.
<T>Manage categories and organize your content structure.</T>
</DialogDescription>
</DialogHeader>
@ -372,7 +377,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
{/* Left: Category Tree */}
<div className="flex-1 border rounded-md p-2 overflow-y-auto min-h-0 basis-[40%] md:basis-auto">
<div className="flex justify-between items-center mb-2 px-2">
<span className="text-sm font-semibold text-muted-foreground">Category Hierarchy</span>
<span className="text-sm font-semibold text-muted-foreground"><T>Category Hierarchy</T></span>
<Button variant="ghost" size="sm" onClick={() => handleCreateStart(null)}>
<Plus className="h-3 w-3 mr-1" />
<T>Root Category</T>
@ -383,7 +388,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
) : (
<div className="space-y-1">
{categories.map(cat => renderCategoryItem(cat))}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8">No categories found.</div>}
{categories.length === 0 && <div className="text-center text-sm text-muted-foreground py-8"><T>No categories found.</T></div>}
</div>
)}
</div>
@ -393,11 +398,11 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
{editingCategory ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{isCreating ? "New Category" : "Edit Category"}</h3>
<h3 className="font-semibold text-sm">{isCreating ? translate("New Category") : translate("Edit Category")}</h3>
<Button variant="ghost" size="sm" onClick={() => setEditingCategory(null)}><X className="h-4 w-4" /></Button>
</div>
<div className="space-y-2">
<Label>Name</Label>
<Label><T>Name</T></Label>
<Input
value={editingCategory.name}
onChange={(e) => {
@ -408,23 +413,23 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
/>
</div>
<div className="space-y-2">
<Label>Slug</Label>
<Label><T>Slug</T></Label>
<Input
value={editingCategory.slug}
onChange={(e) => setEditingCategory({ ...editingCategory, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Visibility</Label>
<Label><T>Visibility</T></Label>
<Select
value={editingCategory.visibility}
onValueChange={(val: any) => setEditingCategory({ ...editingCategory, visibility: val })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="public">Public</SelectItem>
<SelectItem value="unlisted">Unlisted</SelectItem>
<SelectItem value="private">Private</SelectItem>
<SelectItem value="public"><T>Public</T></SelectItem>
<SelectItem value="unlisted"><T>Unlisted</T></SelectItem>
<SelectItem value="private"><T>Private</T></SelectItem>
</SelectContent>
</Select>
</div>
@ -436,7 +441,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</div>
<div className="space-y-2">
<Label>Variables</Label>
<Label><T>Variables</T></Label>
<div className="flex items-center gap-2">
<Button
variant="outline"
@ -444,7 +449,7 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
onClick={() => setShowVariablesEditor(true)}
>
<Hash className="mr-2 h-4 w-4" />
Manage Variables
<T>Manage Variables</T>
{editingCategory.meta?.variables && Object.keys(editingCategory.meta.variables).length > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{Object.keys(editingCategory.meta.variables).length} defined
@ -455,9 +460,9 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</div>
<div className="space-y-2">
<Label>Assigned Types</Label>
<Label><T>Assigned Types</T></Label>
<div className="border rounded-md p-2 max-h-40 overflow-y-auto space-y-2">
{types.length === 0 && <div className="text-xs text-muted-foreground p-1">No assignable types found.</div>}
{types.length === 0 && <div className="text-xs text-muted-foreground p-1"><T>No assignable types found.</T></div>}
{types.map(type => (
<div key={type.id} className="flex items-center gap-2">
<Checkbox
@ -485,12 +490,27 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
))}
</div>
<div className="text-[10px] text-muted-foreground">
Assign types to allow using them in this category.
<T>Assign types to allow using them in this category.</T>
</div>
</div>
{/* Translate button — only for existing categories */}
{!isCreating && editingCategory.id && (
<div className="space-y-2">
<Label><T>Translations</T></Label>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setShowTranslationDialog(true)}
>
<Languages className="mr-2 h-4 w-4" />
<T>Translate</T>
</Button>
</div>
)}
<Button className="w-full" onClick={handleSave} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
<T>Save</T>
</Button>
</div>
) : selectedCategoryId ? (
@ -507,49 +527,49 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
{currentPageId && (
<div className="bg-background rounded p-3 border space-y-2">
<Label className="text-xs uppercase text-muted-foreground">Current Page Link</Label>
<Label className="text-xs uppercase text-muted-foreground"><T>Current Page Link</T></Label>
{linkedCategoryIds.includes(selectedCategoryId) ? (
<div className="text-sm">
<div className="flex items-center gap-2 text-green-600 mb-2">
<Check className="h-4 w-4" />
Page linked to this category
<T>Page linked to this category</T>
</div>
<Button size="sm" variant="outline" className="w-full" onClick={handleUnlinkPage} disabled={actionLoading}>
Remove from Category
<T>Remove from Category</T>
</Button>
</div>
) : (
<Button size="sm" className="w-full" onClick={handleLinkPage} disabled={actionLoading}>
<LinkIcon className="mr-2 h-4 w-4" />
Add to Category
<T>Add to Category</T>
</Button>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
Select a category to see actions or click edit/add icons in the tree.
<T>Select a category to see actions or click edit/add icons in the tree.</T>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm text-center">
Select a category to manage or link.
<T>Select a category to manage or link.</T>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Close</Button>
<Button variant="outline" onClick={onClose}><T>Close</T></Button>
</DialogFooter>
{/* Nested Dialog for Variables */}
<Dialog open={showVariablesEditor} onOpenChange={setShowVariablesEditor}>
<DialogContent className="max-w-4xl h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle>Category Variables</DialogTitle>
<DialogTitle><T>Category Variables</T></DialogTitle>
<DialogDescription>
Define variables available to pages in this category.
<T>Define variables available to pages in this category.</T>
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0">
@ -573,6 +593,17 @@ export const CategoryManager = ({ isOpen, onClose, currentPageId, currentPageMet
</div>
</DialogContent>
</Dialog>
{/* Category Translation Dialog */}
{showTranslationDialog && editingCategory?.id && (
<CategoryTranslationDialog
open={showTranslationDialog}
onOpenChange={setShowTranslationDialog}
categoryId={editingCategory.id}
categoryName={editingCategory.name || ''}
categoryDescription={editingCategory.description}
/>
)}
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,246 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
export interface HomeWidgetProps {
sortBy?: 'latest' | 'top';
viewMode?: 'grid' | 'large' | 'list';
showCategories?: boolean;
categorySlugs?: string;
userId?: string;
showSortBar?: boolean;
showFooter?: boolean;
variables?: Record<string, any>;
}
import type { FeedSortOption } from '@/hooks/useFeedData';
import { useMediaRefresh } from '@/contexts/MediaRefreshContext';
import { useIsMobile } from '@/hooks/use-mobile';
import PhotoGrid from '@/components/PhotoGrid';
import GalleryLarge from '@/components/GalleryLarge';
import MobileFeed from '@/components/feed/MobileFeed';
import { ListLayout } from '@/components/ListLayout';
import CategoryTreeView from '@/components/CategoryTreeView';
import Footer from '@/components/Footer';
import { T } from '@/i18n';
import { SEO } from '@/components/SEO';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { LayoutGrid, GalleryVerticalEnd, TrendingUp, Clock, List, FolderTree } from 'lucide-react';
const SIDEBAR_KEY = 'categorySidebarSize';
const DEFAULT_SIDEBAR = 15;
const HomeWidget: React.FC<HomeWidgetProps> = ({
sortBy: propSortBy = 'latest',
viewMode: propViewMode = 'grid',
showCategories: propShowCategories = false,
categorySlugs: propCategorySlugs = '',
userId: propUserId = '',
showSortBar = true,
showFooter = true,
}) => {
const { refreshKey } = useMediaRefresh();
const isMobile = useIsMobile();
// Local state driven from props (with user overrides)
const [sortBy, setSortBy] = useState<FeedSortOption>(propSortBy);
const [viewMode, setViewMode] = useState<'grid' | 'large' | 'list'>(() => {
return (localStorage.getItem('feedViewMode') as 'grid' | 'large' | 'list') || propViewMode;
});
const [showCategories, setShowCategories] = useState(propShowCategories);
// Sync from prop changes
useEffect(() => { setSortBy(propSortBy); }, [propSortBy]);
useEffect(() => { setShowCategories(propShowCategories); }, [propShowCategories]);
useEffect(() => {
localStorage.setItem('feedViewMode', viewMode);
}, [viewMode]);
const categorySlugs = useMemo(() => {
if (!propCategorySlugs) return undefined;
return propCategorySlugs.split(',').map(s => s.trim()).filter(Boolean);
}, [propCategorySlugs]);
// Derive source/sourceId for user filtering
const feedSource = propUserId ? 'user' as const : 'home' as const;
const feedSourceId = propUserId || undefined;
// Mobile sheet state
const [sheetOpen, setSheetOpen] = useState(false);
const closeSheet = useCallback(() => setSheetOpen(false), []);
// 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 — toggle local state
const handleSortChange = useCallback((value: string) => {
if (!value) return;
if (value === 'latest') setSortBy('latest');
else if (value === 'top') setSortBy('top');
else if (value === 'categories') setShowCategories(prev => !prev);
}, []);
const handleCategoriesToggle = useCallback(() => {
setShowCategories(prev => {
const next = !prev;
if (next && isMobile) setSheetOpen(true);
return next;
});
}, [isMobile]);
// --- Shared sort + categories toggle bar ---
const renderSortBar = (size?: 'sm') => (
<>
<ToggleGroup type="single" value={sortBy} onValueChange={handleSortChange}>
<ToggleGroupItem value="latest" aria-label="Latest Posts" size={size}>
<Clock className="h-4 w-4 mr-2" />
<T>Latest</T>
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts" size={size}>
<TrendingUp className="h-4 w-4 mr-2" />
<T>Top</T>
</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" />
<T>Categories</T>
</ToggleGroupItem>
</ToggleGroup>
</>
);
// --- Feed views ---
const renderFeed = () => {
if (isMobile) {
return viewMode === 'list' ? (
<ListLayout key={refreshKey} sortBy={sortBy} navigationSource={feedSource} navigationSourceId={feedSourceId} categorySlugs={categorySlugs} />
) : (
<MobileFeed source={feedSource} sourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} onNavigate={(id) => window.location.href = `/post/${id}`} />
);
}
if (viewMode === 'grid') {
return <PhotoGrid key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} showVideos={true} categorySlugs={categorySlugs} />;
} else if (viewMode === 'large') {
return <GalleryLarge key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} sortBy={sortBy} categorySlugs={categorySlugs} />;
}
return <ListLayout key={refreshKey} navigationSource={feedSource} navigationSourceId={feedSourceId} 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"><T>Categories</T></SheetTitle>
<SheetDescription className="sr-only"><T>Browse categories</T></SheetDescription>
</SheetHeader>
<div className="overflow-y-auto flex-1">
<CategoryTreeView onNavigate={closeSheet} filterType="pages" />
</div>
</SheetContent>
</Sheet>
)}
<div className="md:py-2">
{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">
{showSortBar && renderSortBar('sm')}
</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>
) : 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">
{showSortBar && 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"><T>Categories</T></span>
</div>
<CategoryTreeView filterType="pages" />
</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">
{showSortBar && 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>
{showFooter && <Footer />}
</div>
);
};
export default HomeWidget;

View File

@ -673,7 +673,7 @@ Return ONLY the optimized prompt, no explanations or additional text.`;
// Normal Card layout
return (
<Card>
<Card onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between flex-nowrap">
<div className="flex items-center gap-2 flex-nowrap shrink-0">

View File

@ -35,7 +35,7 @@ const MarkdownTextWidget: React.FC<MarkdownTextWidgetProps> = ({
// If in layout edit mode and not editing, show preview
if (isEditMode && !isEditing) {
return (
<Card className="border-2 border-dashed">
<Card className="border-2 border-dashed" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<CardContent className="p-4">
{propContent ? (
<div className="space-y-3">
@ -81,27 +81,29 @@ const MarkdownTextWidget: React.FC<MarkdownTextWidgetProps> = ({
// Editing mode (layout edit mode + editing state)
if (isEditMode && isEditing) {
return (
<Suspense fallback={
<Card>
<CardContent className="p-8 text-center">
<div className="animate-pulse flex flex-col items-center">
<div className="h-12 w-12 bg-muted rounded-full mb-4"></div>
<div className="h-4 w-48 bg-muted rounded mb-2"></div>
<div className="h-3 w-32 bg-muted rounded"></div>
</div>
<p className="mt-4 text-muted-foreground"><T>Loading Editor...</T></p>
</CardContent>
</Card>
}>
<MarkdownTextWidgetEdit
content={propContent}
placeholder={propPlaceholder}
templates={propTemplates}
onPropsChange={onPropsChange}
contextVariables={contextVariables}
onClose={() => setIsEditing(false)}
/>
</Suspense>
<div onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<Suspense fallback={
<Card>
<CardContent className="p-8 text-center">
<div className="animate-pulse flex flex-col items-center">
<div className="h-12 w-12 bg-muted rounded-full mb-4"></div>
<div className="h-4 w-48 bg-muted rounded mb-2"></div>
<div className="h-3 w-32 bg-muted rounded"></div>
</div>
<p className="mt-4 text-muted-foreground"><T>Loading Editor...</T></p>
</CardContent>
</Card>
}>
<MarkdownTextWidgetEdit
content={propContent}
placeholder={propPlaceholder}
templates={propTemplates}
onPropsChange={onPropsChange}
contextVariables={contextVariables}
onClose={() => setIsEditing(false)}
/>
</Suspense>
</div>
);
}

View File

@ -15,6 +15,10 @@ interface PhotoCardWidgetProps {
pictureId?: string | null;
showHeader?: boolean;
showFooter?: boolean;
showAuthor?: boolean;
showActions?: boolean;
showTitle?: boolean;
showDescription?: boolean;
contentDisplay?: 'below' | 'overlay' | 'overlay-always';
imageFit?: 'contain' | 'cover';
// Widget instance management
@ -43,6 +47,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
pictureId: propPictureId = null,
showHeader = true,
showFooter = true,
showAuthor = true,
showActions = true,
showTitle = true,
showDescription = true,
contentDisplay = 'below',
imageFit = 'cover',
onPropsChange
@ -320,6 +328,10 @@ const PhotoCardWidget: React.FC<PhotoCardWidgetProps> = ({
overlayMode={contentDisplay === 'overlay-always' ? 'always' : 'hover'}
showHeader={showHeader}
showContent={showFooter}
showAuthor={showAuthor}
showActions={showActions}
showTitle={showTitle}
showDescription={showDescription}
isExternal={isExternal}
imageFit={imageFit}
/>

View File

@ -7,18 +7,18 @@ import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { WidgetDefinition } from '@/lib/widgetRegistry';
import { ImagePickerDialog } from './ImagePickerDialog';
import { PagePickerDialog } from '@/modules/pages/PagePickerDialog';
import { Image as ImageIcon, Maximize2, FileText, Sparkles, FolderOpen } from 'lucide-react';
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import MarkdownEditor from '@/components/MarkdownEditorEx';
import { TailwindClassPicker } from './TailwindClassPicker';
import { TabsPropertyEditor } from './TabsPropertyEditor';
const TabsPropertyEditor = React.lazy(() => import('./TabsPropertyEditor').then(m => ({ default: m.TabsPropertyEditor })));
import { HtmlGeneratorWizard } from './HtmlGeneratorWizard';
import { FileBrowserWidget } from '@/modules/storage';
import { UserPicker } from '@/components/admin/UserPicker';
export interface WidgetPropertiesFormProps {
widgetDefinition: WidgetDefinition;
widgetInstanceId?: string;
@ -373,11 +373,13 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<TabsPropertyEditor
value={value || []}
onChange={(newValue) => updateSetting(key, newValue)}
widgetInstanceId={widgetInstanceId || 'new-widget'}
/>
<React.Suspense fallback={<div className="text-xs text-muted-foreground">Loading editor...</div>}>
<TabsPropertyEditor
value={value || []}
onChange={(newValue) => updateSetting(key, newValue)}
widgetInstanceId={widgetInstanceId || 'new-widget'}
/>
</React.Suspense>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
@ -442,6 +444,35 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
);
}
case 'userPicker':
return (
<div key={key} className="space-y-2">
<Label htmlFor={key} className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<UserPicker
value={value || ''}
onSelect={(userId) => updateSetting(key, userId)}
/>
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive"
onClick={() => updateSetting(key, '')}
>
<T>Clear user filter</T>
</Button>
)}
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
default:
return null;
}

View File

@ -13,6 +13,7 @@ interface AuthContextType {
signIn: (email: string, password: string) => Promise<{ error: any }>;
signInWithGithub: () => Promise<{ error: any }>;
signInWithGoogle: () => Promise<{ error: any }>;
resetPassword: (email: string) => Promise<{ error: any }>;
signOut: () => Promise<void>;
}
@ -221,6 +222,28 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
return { error };
};
const resetPassword = async (email: string) => {
const baseUrl = import.meta.env.VITE_REDIRECT_URL || window.location.origin;
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${baseUrl}/auth/update-password`,
});
if (error) {
toast({
title: 'Reset failed',
description: error.message,
variant: 'destructive',
});
} else {
toast({
title: 'Check your email',
description: 'A password reset link has been sent to your email.',
});
}
return { error };
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
@ -248,6 +271,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
signIn,
signInWithGithub,
signInWithGoogle,
resetPassword,
signOut,
}}>
{children}

View File

@ -3,6 +3,7 @@ import { FEED_API_ENDPOINT, FEED_PAGE_SIZE } from '@/constants';
import { useProfiles } from '@/contexts/ProfilesContext';
import { useFeedCache } from '@/contexts/FeedCacheContext';
import { augmentFeedPosts, FeedPost } from '@/modules/posts/client-posts';
import { getCurrentLang } from '@/i18n';
const { supabase } = await import('@/integrations/supabase/client');
@ -119,7 +120,7 @@ export const useFeedData = ({
// Let's use API as primary.
if (true) {
const SERVER_URL = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
let queryParams = `?page=${currentPage}&limit=${FEED_PAGE_SIZE}&sortBy=${sortBy}`;
let queryParams = `?page=${currentPage}&limit=${FEED_PAGE_SIZE}&sortBy=${sortBy}&lang=${getCurrentLang()}`;
if (source) queryParams += `&source=${source}`;
if (sourceId) queryParams += `&sourceId=${sourceId}`;
if (categoryIds && categoryIds.length > 0) queryParams += `&categoryIds=${categoryIds.join(',')}`;

View File

@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import type { AppConfig } from '../../shared/src/config/config';
interface SystemInfo {
env: Record<string, string>;
env: Record<string, string> & { appConfig?: AppConfig };
}
export const useSystemInfo = () => {
@ -22,3 +23,9 @@ export const useEnvVar = (key: string): string | undefined => {
const { data } = useSystemInfo();
return data?.env[key];
};
/** Shorthand: get the app config */
export const useAppConfig = (): AppConfig | undefined => {
const { data } = useSystemInfo();
return data?.env as unknown as AppConfig;
};

View File

@ -10,12 +10,38 @@ export const supportedLanguages = [
{ code: 'sw', name: 'Kiswahili' },
{ code: 'de', name: 'Deutsch' },
{ code: 'es', name: 'Español' },
{ code: 'nl', name: 'Nederlands' }
{ code: 'nl', name: 'Nederlands' },
{ code: 'ja', name: '日本語' },
{ code: 'ko', name: '한국어' },
{ code: 'pt', name: 'Português' },
{ code: 'ru', name: 'Русский' },
{ code: 'tr', name: 'Türkçe' },
{ code: 'zh', name: '中文' }
];
// --- LocalStorage persistence for requested terms ---
const LS_KEY = 'i18n-requested-terms';
const loadRequestedTerms = (): { [lang: string]: Record<string, string> } => {
if (typeof window === 'undefined') return {};
try {
const stored = localStorage.getItem(LS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
};
const saveRequestedTerms = (cache: { [lang: string]: Record<string, string> }) => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(LS_KEY, JSON.stringify(cache));
} catch { /* quota exceeded — ignore */ }
};
// --- Caching and Loading ---
const translationCache: { [lang: string]: Record<string, string> } = {};
const translationCache: { [lang: string]: Record<string, string> } = loadRequestedTerms();
const loadingPromises: { [lang: string]: Promise<Record<string, string>> } = {};
const translationsLoaded: { [lang: string]: boolean } = {};
@ -31,34 +57,27 @@ const notify = () => {
listeners.forEach(l => l());
};
// Pre-register all i18n JSON modules eagerly — Vite HMR will update on file change
const langModules = import.meta.glob('./i18n/*.json', { eager: true }) as Record<string, { default: Record<string, string> }>;
const loadTranslations = (lang: LangCode): Promise<Record<string, string>> => {
if (translationsLoaded[lang]) {
return Promise.resolve(translationCache[lang] || {});
}
return loadingPromises[lang] || (() => {
const promise = import(`./i18n/${lang}.json`)
.then(module => {
const translations = module.default;
// Merge with existing auto-collected keys, but prioritize loaded translations
const existingCache = translationCache[lang] || {};
translationCache[lang] = { ...existingCache, ...translations };
translationsLoaded[lang] = true;
delete loadingPromises[lang];
notify(); // Notify components to re-render with new translations
return translationCache[lang];
})
.catch(error => {
console.warn(`Could not load translations for language: ${lang}`, error);
// Don't mark as loaded if it failed, but keep auto-collected keys
delete loadingPromises[lang];
notify(); // Still notify to update state
return translationCache[lang] || {};
});
const key = `./i18n/${lang}.json`;
const mod = langModules[key];
if (!mod) {
console.warn(`No i18n file found for language: ${lang}`);
return Promise.resolve(translationCache[lang] || {});
}
loadingPromises[lang] = promise;
return promise;
})();
const translations = mod.default;
const existingCache = translationCache[lang] || {};
translationCache[lang] = { ...existingCache, ...translations };
translationsLoaded[lang] = true;
notify();
return Promise.resolve(translationCache[lang]);
};
// Helper to get cookie value
@ -80,21 +99,21 @@ const setCookie = (name: string, value: string, days: number = 365) => {
const getCurrentLangInternal = (): LangCode => {
if (typeof window !== 'undefined') {
// 1. Check cookie first
const cookieLang = getCookie('lang') as LangCode;
if (cookieLang && cookieLang.length === 2) {
return cookieLang;
}
// 2. Check URL parameter (for backward compatibility)
// 1. Check URL parameter first (highest priority — allows shareable links)
const params = new URLSearchParams(window.location.search);
const langParam = params.get('lang') as LangCode;
if (langParam && langParam.length === 2) {
// Save to cookie for future visits
// Save to cookie so subsequent navigations keep the language
setCookie('lang', langParam);
return langParam;
}
// 2. Check cookie
const cookieLang = getCookie('lang') as LangCode;
if (cookieLang && cookieLang.length === 2) {
return cookieLang;
}
// 3. Fallback to browser language
const browserLangs = navigator.languages || [navigator.language];
for (const lang of browserLangs) {
@ -138,6 +157,7 @@ export const translate = (textKey: string, langParam?: LangCode): string => {
}
if (translationCache[langToUse][textKey] === undefined) {
translationCache[langToUse][textKey] = textKey; // Store key as value for now
saveRequestedTerms(translationCache); // Persist to localStorage
}
// 4. Ultimate fallback: return the key itself
@ -235,12 +255,31 @@ export const getTranslationStatus = (lang?: LangCode) => {
return status;
};
// Merge external translations into cache and persist
export const mergeTranslations = (lang: LangCode, translations: Record<string, string>) => {
if (!translationCache[lang]) {
translationCache[lang] = {};
}
Object.assign(translationCache[lang], translations);
saveRequestedTerms(translationCache);
notify();
};
// Read persisted requested terms from localStorage
export const getRequestedTerms = (lang?: LangCode): Record<string, string> | { [lang: string]: Record<string, string> } => {
const stored = loadRequestedTerms();
if (lang) return stored[lang] || {};
return stored;
};
if (typeof window !== 'undefined') {
(window as any).getTranslationCache = getTranslationCache;
(window as any).translate = translate;
(window as any).getCurrentLang = getCurrentLang;
(window as any).downloadTranslations = downloadTranslations;
(window as any).getTranslationStatus = getTranslationStatus;
(window as any).mergeTranslations = mergeTranslations;
(window as any).getRequestedTerms = getRequestedTerms;
}
export { T };

View File

@ -1,104 +1,174 @@
{
"Search pictures, users, collections...": "Suche nach Bildern, Benutzern, Sammlungen...",
"Search": "Suche",
"10001": "10001",
"# Common Features\n\n* Supported protocols: ModbusTCP, Serial, WebSocket and REST\n* Modern web interface with built-in HMI designer\n* Many safety features for fault detection, overload and overheat\n* Supported: Omron PIDs and VFDs, Delta VFD, Sako VFD\n\n### Features Sheetpress\n\n* Sequential Heating to deal with power supply limits\n* Supports up to 32 PID controllers, Omron E5x - via Modbus-RTU\n* Temperature & Pressure Profiles\n* Adaptive Pressure\n* Cost monitoring\n\n### Features Injection Machine\n\n* Temperature & Pressure Profiles\n* Adaptive Pressure / Post flow\n\n### Features Extrusion Machine\n\n* Temperature & Pressure Profiles\n* Adaptive Pressure / Post flow\n\n## Resources\n\n* [Knowledgebase](https://polymech.info/en/resources/cassandra/home/)\n* [Modbus Interface](https://polymech.info/en/resources/cassandra/modbus/)\n* [Cassandra Firmware source](https://git.polymech.info/polymech/firmware-base)\n* [Cassandra Firmware documentation - DeepWiki](https://deepwiki.com/polymech-info/firmware/1-overview)": "# Gemeinsame Merkmale\n\n* Unterstützte Protokolle: ModbusTCP, Seriell, WebSocket und REST\n* Moderne Web-Schnittstelle mit integriertem HMI-Designer\n* Viele Sicherheitsfunktionen für Fehlererkennung, Überlast und Überhitzung\n* Unterstützt: Omron PIDs und VFDs, Delta VFD, Sako VFD\n\n### Merkmale Sheetpress\n\n* Sequentielles Heizen für den Umgang mit Stromversorgungsgrenzen\n* Unterstützt bis zu 32 PID-Regler, Omron E5x - über Modbus-RTU\n* Temperatur- und Druckprofile\n* Adaptiver Druck\n* Kostenüberwachung\n\n### Merkmale Einspritzmaschine\n\n* Temperatur- und Druckprofile\n* Adaptiver Druck / Nachfluss\n\n### Merkmale Extrusionsmaschine\n\n* Temperatur- & Druckprofile\n* Adaptiver Druck / Nachlauf\n\n## Ressourcen\n\n* [Knowledgebase](https://polymech.info/en/resources/cassandra/home/)\n* [Modbus-Schnittstelle](https://polymech.info/en/resources/cassandra/modbus/)\n* [Cassandra Firmware-Quelle](https://git.polymech.info/polymech/firmware-base)\n* [Cassandra-Firmware-Dokumentation - DeepWiki](https://deepwiki.com/polymech-info/firmware/1-overview)",
"+1 555 000 0000": "+1 555 000 0000",
"+1 555 123 4567": "+1 555 123 4567",
"<div class=\"max-w-2xl mx-auto p-4 bg-white dark:bg-gray-900 rounded-lg shadow-md\">\n <table class=\"w-full border-collapse\">\n <thead>\n <tr class=\"bg-gray-100 dark:bg-gray-800\">\n <th class=\"py-2 px-4 text-left text-gray-700 dark:text-gray-300\">Specification</th>\n <th class=\"py-2 px-4 text-left text-gray-700 dark:text-gray-300\">Details</th>\n </tr>\n </thead>\n <tbody>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Name</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${name}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Language</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${language}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Show Table of Contents</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${ShowToc}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Category Variable</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${cat_var}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Product Category Number</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${ProductCatNb}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Price</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${price}</td>\n </tr>\n <tr>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Country</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${country}</td>\n </tr>\n </tbody>\n </table>\n</div>": "<div class=\"max-w-2xl mx-auto p-4 bg-white dark:bg-gray-900 rounded-lg shadow-md\">\n <table class=\"w-full border-collapse\">\n <thead>\n <tr class=\"bg-gray-100 dark:bg-gray-800\">\n <th class=\"py-2 px-4 text-left text-gray-700 dark:text-gray-300\">Beschreibung</th>\n <th class=\"py-2 px-4 text-left text-gray-700 dark:text-gray-300\">Details</th>\n </tr>\n </thead>\n <tbody>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Name</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${Name}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Sprache</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${Sprache}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Inhaltsverzeichnis anzeigen</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${ShowToc}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Kategorie-Variable</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${cat_var}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Produktkategorienummer</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${ProduktKatNb}</td>\n </tr>\n <tr class=\"border-b border-gray-200 dark:border-gray-700\">\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Preis</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${Preis}</td>\n </tr>\n <tr>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">Land</td>\n <td class=\"py-2 px-4 text-gray-800 dark:text-gray-200\">${Land}</td>\n </tr>\n </tbody>\n </table>\n</div>",
"123 Main St": "123 Hauptstraße",
"123 Warehouse Blvd": "123 Warehouse Blvd",
"A widget that contains its own independent layout canvas.": "Ein Widget, das seine eigene unabhängige Layout-Leinwand enthält.",
"About Violations": "Über Verstöße",
"accel": "beschleunigung",
"Access Control": "Zugangskontrolle",
"Access Point (AP) Mode": "Zugangspunkt (AP) Modus",
"Acme Corp": "Acme Corp.",
"Actions": "Aktionen",
"Active Permissions": "Aktive Berechtigungen",
"Active Violations": "Aktive Verstöße",
"Add a comment...": "Einen Kommentar hinzufügen...",
"Add a set of sample control points to this plot": "Hinzufügen eines Satzes von Probenkontrollpunkten zu dieser Darstellung",
"Add Address": "Adresse hinzufügen",
"Add all": "Alle hinzufügen",
"Add Child": "Kind hinzufügen",
"Add Container": "Container hinzufügen",
"Add rich text content with Markdown support": "Hinzufügen von Rich-Text-Inhalten mit Markdown-Unterstützung",
"Add Samples": "Proben hinzufügen",
"Add Slave": "Slave hinzufügen",
"Add to Cart": "Zum Warenkorb hinzufügen",
"Add Vendor Profile": "Anbieterprofil hinzufügen",
"Add Widget": "Widget hinzufügen",
"Add:": "Hinzufügen:",
"Added to cart": "In den Warenkorb gelegt",
"Addr:": "Addr:",
"Address": "Adresse",
"Address Picker": "Adressausleser",
"Admin": "Verwaltung",
"Advanced": "Fortgeschrittene",
"ADVANCED": "ADVANCED",
"Agent": "Agent",
"AI Assistant": "KI-Assistent",
"AI Generator": "AI-Generator",
"AI Image Generator": "AI Image Generator",
"Language": "Sprache",
"Sign in": "Eintragen",
"Loading...": "Laden...",
"My Profile": "Mein Profil",
"Enter your Google API key": "Geben Sie Ihren Google API-Schlüssel ein",
"Enter your OpenAI API key": "Geben Sie Ihren OpenAI API-Schlüssel ein",
"General": "Allgemein",
"Organizations": "Organisationen",
"API Keys": "API-Schlüssel",
"Profile": "Profil",
"Gallery": "Galerie",
"Profile Settings": "Profil-Einstellungen",
"Manage your account settings and preferences": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen",
"Google API Key": "Google API-Schlüssel",
"For Google services (stored securely)": "Für Google-Dienste (sicher gespeichert)",
"OpenAI API Key": "OpenAI API-Schlüssel",
"For AI image generation (stored securely)": "Für die Erzeugung von AI-Bildern (sicher gespeichert)",
"Save API Keys": "API-Schlüssel speichern",
"AI Image Wizard": "AI Image Wizard",
"AI Layout": "AI-Layout",
"AI Model": "AI-Modell",
"AI Provider": "AI-Anbieter",
"AIMLAPI API Key": "AIMLAPI-API-Schlüssel",
"All Stop": "Alle Haltestelle",
"Allow any logged-in user access on": "Erlauben Sie jedem angemeldeten Benutzer den Zugriff auf",
"Allow unauthenticated access on": "Erlauben Sie unauthentifizierten Zugriff auf",
"Analytics": "Analytik",
"Anonymous": "Anonym",
"Anonymous Access": "Anonymer Zugang",
"AP Gateway": "AP-Gateway",
"AP IP Address": "AP-IP-Adresse",
"AP Password": "AP-Kennwort",
"AP SSID": "AP SSID",
"AP Subnet Mask": "AP-Subnetzmaske",
"API Keys": "API-Schlüssel",
"API URL": "API-URL",
"Append": "Anhängen",
"Apply": "Bewerbung",
"Apply Layout": "Layout anwenden",
"Apply:": "Anwenden:",
"Are you sure you want to delete this picture?": "Sind Sie sicher, dass Sie dieses Bild löschen wollen?",
"Argument 0:": "Argument 0:",
"Argument 1:": "Argument 1:",
"Argument 2 (Optional):": "Argument 2 (fakultativ):",
"Arguments:": "Argumente:",
"Aspect Ratio": "Bildseitenverhältnis",
"Associated Controllers:": "Zugehörige Steuergeräte:",
"Associated Signal Plot (Optional)": "Zugehöriges Signaldiagramm (optional)",
"Authenticated Users": "Authentifizierte Benutzer",
"AUTO": "AUTO",
"AUTO MULTI": "AUTO MULTI",
"AUTO MULTI BALANCED": "AUTO-MULTI AUSGEGLICHEN",
"AUTO_MULTI": "AUTO_MULTI",
"AUTO_MULTI_BALANCED": "AUTO_MULTI_BALANCED",
"AUTO_TIMEOUT": "AUTO_TIMEOUT",
"Access Point (AP) Mode": "Zugangspunkt (AP) Modus",
"Add Container": "Container hinzufügen",
"Add Samples": "Proben hinzufügen",
"Add Slave": "Slave hinzufügen",
"Add Widget": "Widget hinzufügen",
"Add a set of sample control points to this plot": "Hinzufügen eines Satzes von Probenkontrollpunkten zu dieser Darstellung",
"Add all": "Alle hinzufügen",
"Addr:": "Addr:",
"Address Picker": "Adressausleser",
"Advanced": "Fortgeschrittene",
"All Stop": "Alle Haltestelle",
"Apply": "Bewerbung",
"Argument 0:": "Argument 0:",
"Argument 1:": "Argument 1:",
"Argument 2 (Optional):": "Argument 2 (fakultativ):",
"Arguments:": "Argumente:",
"Associated Controllers:": "Zugehörige Steuergeräte:",
"Associated Signal Plot (Optional)": "Zugehöriges Signaldiagramm (optional)",
"Auto-Ban Threshold:": "Auto-Sperr-Schwelle:",
"Auto-Refresh": "Auto-Refresh",
"Aux": "Aux",
"Back to feed": "Zurück zu Futtermittel",
"Back to Home": "Zurück zu Home",
"BALANCE": "BALANCE",
"BALANCE_MAX_DIFF": "BALANCE_MAX_DIFF",
"Ban Management": "Verbotsverwaltung",
"Banned IP Addresses": "Gesperrte IP-Adressen",
"Banned IPs": "Gesperrte IPs",
"Banned Tokens": "Verbotene Wertmarken",
"Banned Users": "Gesperrte Benutzer",
"Bans": "Verbote",
"Battleground": "Schlachtfeld",
"Be the first to comment!": "Seien Sie der Erste, der einen Kommentar abgibt!",
"Be the first to like this": "Sei der Erste, dem dies gefällt",
"Bio": "Bio",
"Blank - Test": "Leer - Test",
"Boolean": "Boolesche",
"Bria API Key": "Bria API-Schlüssel",
"Browse files and directories on VFS mounts": "Dateien und Verzeichnisse auf VFS-Mounts durchsuchen",
"Buzzer": "Buzzer",
"Buzzer: Fast Blink": "Buzzer: Schnelles Blinken",
"Buzzer: Long Beep/Short Pause": "Buzzer: Langer Signalton/kurze Pause",
"Buzzer: Off": "Buzzer: Aus",
"Buzzer: Slow Blink": "Buzzer: Langsames Blinken",
"Buzzer: Solid On": "Buzzer: Dauerhaft eingeschaltet",
"CE": "CE",
"COM Write": "COM Schreiben",
"CP Description (Optional):": "CP-Beschreibung (fakultativ):",
"CP Name (Optional):": "CP-Name (fakultativ):",
"CSV": "CSV",
"Cache Control": "Cache-Steuerung",
"Call Function": "Funktion aufrufen",
"Call Method": "Methode aufrufen",
"Call REST API": "REST-API aufrufen",
"Cancel": "Abbrechen",
"Cancel Generation": "Generation abbrechen",
"Carina": "Carina",
"Cart": "Wagen",
"Cassandra Left": "Cassandra Links",
"Cassandra Right": "Cassandra Rechts",
"Castor": "Castor",
"Categories": "Kategorien",
"Category Hierarchy": "Hierarchie der Kategorien",
"Category Manager": "Kategorie-Manager",
"CE": "CE",
"Cetus": "Cetus",
"Change Avatar": "Avatar ändern",
"Change Password": "Passwort ändern",
"Charts": "Diagramme",
"Child Profiles (Sub-plots)": "Profile der Kinder (Nebenhandlungen)",
"Choose a picture from your published images": "Wählen Sie ein Bild aus Ihren veröffentlichten Bildern",
"Choose File": "Datei auswählen",
"Choose Files": "Dateien auswählen",
"Choose Files or Drop Here": "Dateien auswählen oder hier ablegen",
"Choose from the options below to start generating or uploading content.": "Wählen Sie eine der folgenden Optionen, um mit dem Erstellen oder Hochladen von Inhalten zu beginnen.",
"Choose or Drop Files": "Dateien auswählen oder ablegen",
"City": "Stadt",
"Cleanup:": "Aufräumen:",
"Clear": "Klar",
"Clear All": "Alle löschen",
"Clear All CPs": "Alle CPs löschen",
"Clear Chart": "Übersichtliches Diagramm",
"Clears the server-side content cache (memory) and the disk-based image cache. Use this if content is not updating correctly.": "Löscht den serverseitigen Inhalts-Cache (Speicher) und den Bild-Cache auf der Festplatte. Verwenden Sie diese Option, wenn der Inhalt nicht korrekt aktualisiert wird.",
"Click \"Add Container\" to start building your layout": "Klicken Sie auf \"Container hinzufügen\", um mit der Erstellung Ihres Layouts zu beginnen",
"Click \"Add Widget\" to start building your HMI": "Klicken Sie auf \"Widget hinzufügen\", um mit der Erstellung Ihrer HMI zu beginnen",
"Click types in the top bar to add variables": "Klicken Sie in der oberen Leiste auf Typen, um Variablen hinzuzufügen",
"Clipboard": "Zwischenablage",
"Close": "Schließen Sie",
"Coil to Write:": "Spule zum Schreiben:",
"Coils": "Spulen",
"Collections": "Sammlungen",
"Color": "Farbe",
"COM Write": "COM Schreiben",
"Coma B": "Koma B",
"Commons": "Commons",
"Components": "Komponenten",
"Configure": "Konfigurieren Sie",
"Configure the new control point. Press Enter to confirm or Esc to cancel.": "Konfigurieren Sie den neuen Kontrollpunkt. Drücken Sie die Eingabetaste zur Bestätigung oder Esc zum Abbrechen.",
"Configure the series to be displayed on the chart.": "Konfigurieren Sie die Serien, die im Diagramm angezeigt werden sollen.",
"Connect": "Verbinden Sie",
"Connect": "Verbinden",
"Connect to a Modbus server to see controller data.": "Stellen Sie eine Verbindung zu einem Modbus-Server her, um die Daten der Steuerung zu sehen.",
"Connect to view register data.": "Verbinden Sie sich, um Registerdaten anzuzeigen.",
"Connected, but no register data received yet. Waiting for data...": "Verbunden, aber noch keine Registerdaten empfangen. Ich warte auf Daten...",
"Connects to an existing Wi-Fi network.": "Stellt eine Verbindung zu einem bestehenden Wi-Fi-Netzwerk her.",
"Container": "Container",
"Containers": "Behältnisse",
"content": "Schlachtfeld",
"Content": "Inhalt",
"Context Preset (Optional)": "Kontextvoreinstellung (optional)",
"Context Source": "Kontext Quelle",
"Continue": "Weiter",
"Control Points": "Kontrollpunkte",
"Control Points List": "Liste der Kontrollpunkte",
"Controller Chart": "Controller-Diagramm",
"Controller Partitions": "Controller Partitionen",
"Copy": "Kopieren",
"Copy \"{plotName}\" to...": "Kopieren Sie \"{plotName}\" nach...",
"Copy \"{profileName}\" to...": "Kopieren Sie \"{Profilname}\" nach...",
"Copy this plot to another slot...": "Kopieren Sie diesen Plot in einen anderen Slot...",
@ -107,188 +177,442 @@
"Copy...": "Kopieren...",
"Corona": "Corona",
"Corvus": "Corvus",
"Country": "Land",
"CP Description (Optional):": "CP-Beschreibung (fakultativ):",
"CP Name (Optional):": "CP-Name (fakultativ):",
"Crater": "Krater",
"Create a Page From Scratch": "Eine Seite von Grund auf neu erstellen",
"Create Control Point": "Kontrollpunkt erstellen",
"Create New Control Point": "Neuen Kontrollpunkt erstellen",
"Create Post": "Beitrag erstellen",
"Create User": "Benutzer erstellen",
"Create Your First Post": "Erstellen Sie Ihren ersten Beitrag",
"Created": "Erstellt",
"Creates its own Wi-Fi network.": "Erzeugt ein eigenes Wi-Fi-Netzwerk.",
"Crux": "Crux",
"CSV": "CSV",
"Current": "Aktuell",
"Current Status": "Aktueller Stand",
"Currently tracked violation records": "Derzeit verfolgte Verstoßdatensätze",
"Custom Widgets": "Benutzerdefinierte Widgets",
"DEC": "DEC",
"Dashboard": "Dashboard",
"Data": "Daten",
"DE": "DE",
"DE123456789": "DE123456789",
"DE123456789000": "DE123456789000",
"DEC": "DEC",
"decel": "abbremsen",
"Default": "Standard",
"Delete": "Löschen",
"Delete Profile": "Profil löschen",
"Delete control point": "Kontrollpunkt löschen",
"Delete Layout": "Layout löschen",
"Delete Picture": "Bild löschen",
"Delete Post": "Beitrag löschen",
"Delete Profile": "Profil löschen",
"Delete template": "Vorlage löschen",
"Delivery Note": "Lieferschein",
"Delta Vfd[15]": "Delta Vfd[15]",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Beschreiben Sie das Bild, das Sie erstellen oder bearbeiten möchten... (Strg+V zum Einfügen von Bildern)",
"Describe the page you want the AI to create. It can generate text and images for you.": "Beschreiben Sie die Seite, die die KI erstellen soll. Sie kann Text und Bilder für Sie generieren.",
"Describe what you want to generate...\n\nKeyboard shortcuts:\n• Ctrl+Enter: Generate\n• Ctrl+↑/↓: Navigate history": "Beschreiben Sie, was Sie erzeugen wollen...\n\nTastaturkürzel:\n- Strg+Eingabe: Erzeugen\n- Strg+↑/↓: In der Historie navigieren",
"Describe your photo... You can use **markdown** formatting!": "Beschreiben Sie Ihr Foto... Sie können **Markdown** Formatierung verwenden!",
"Description": "Beschreibung",
"Description (Optional)": "Beschreibung (fakultativ)",
"DESIGN": "DESIGN",
"Developer": "Entwickler",
"Device Hostname": "Hostname des Geräts",
"Disable All": "Alle deaktivieren",
"Discard & Exit": "Verwerfen & Beenden",
"Discard changes?": "Änderungen verwerfen?",
"Disconnect": "Trennen Sie die Verbindung",
"Display a customizable grid of selected photos": "Anzeige eines anpassbaren Rasters mit ausgewählten Fotos",
"Display a single page card with details": "Anzeige einer einseitigen Karte mit Details",
"Display a single photo card with details": "Anzeige einer einzelnen Fotokarte mit Details",
"Display Message": "Meldung anzeigen",
"Display Name": "Name anzeigen",
"Display photos in a responsive grid layout": "Fotos in einem responsiven Rasterlayout anzeigen",
"Done": "Erledigt",
"Download": "Herunterladen",
"Download All JSON": "Alle JSON herunterladen",
"Download English Translations": "Englische Übersetzungen herunterladen",
"Download JSON for {name}": "JSON für {Name} herunterladen",
"Download Plot": "Plot herunterladen",
"Drag and resize widgets": "Widgets ziehen und Größe ändern",
"Drag images or videos anywhere to start": "Ziehen Sie Bilder oder Videos irgendwo hin, um zu starten",
"Drop files to upload": "Dateien zum Hochladen ablegen",
"Dump JSON": "JSON ausgeben",
"Duplicate Profile": "Profil duplizieren",
"Duration (hh:mm:ss)": "Dauer (hh:mm:ss)",
"Duration:": "Dauer:",
"e.g. Cyberpunk Portrait": "z.B. Cyberpunk Portrait",
"e.g. Home, Office": "z.B. Zuhause, Büro",
"e.g. Main Business, EU Branch": "z. B. Hauptgeschäft, EU-Niederlassung",
"e.g. Ring doorbell, leave at reception…": "z.B. an der Tür klingeln, an der Rezeption abgeben...",
"E.g., Quick Ramp Up": "Z.B. Quick Ramp Up",
"ERROR": "ERROR",
"e.g., Start Heating": "z.B. Start Heizung",
"e.g., Turn on coil for pre-heating stage": "z.B., Einschalten der Spule für die Vorwärmstufe",
"Edit": "Bearbeiten",
"Edit Profile": "Profil bearbeiten",
"Edit Address": "Adresse bearbeiten",
"Edit Details": "Details bearbeiten",
"Edit mode: Add, move, and configure widgets": "Bearbeitungsmodus: Widgets hinzufügen, verschieben und konfigurieren",
"Edit mode: Configure containers and add widgets": "Bearbeitungsmodus: Container konfigurieren und Widgets hinzufügen",
"Edit Picture": "Bild bearbeiten",
"Edit Profile": "Profil bearbeiten",
"Edit Tags": "Tags bearbeiten",
"Edit with AI Wizard": "Bearbeiten mit AI Wizard",
"Email": "E-Mail",
"Email sent successfully!": "E-Mail erfolgreich gesendet!",
"Empty Canvas": "Leere Leinwand",
"Empty Layout": "Leeres Layout",
"Enable All": "Alle freigeben",
"Enable control unavailable for {name}": "Aktivieren der Kontrolle nicht verfügbar für {Name}",
"Enabled": "Aktiviert",
"End Index": "Ende Index",
"Enter a title...": "Geben Sie einen Titel ein...",
"Enter CP description": "CP-Beschreibung eingeben",
"Enter CP name": "CP-Name eingeben",
"Enter display name": "Anzeigename eingeben",
"Enter username": "Benutzernamen eingeben",
"Enter your AIMLAPI API key": "Geben Sie Ihren AIMLAPI-API-Schlüssel ein",
"Enter your Bria API key": "Geben Sie Ihren Bria API-Schlüssel ein",
"Enter your Google API key": "Geben Sie Ihren Google API-Schlüssel ein",
"Enter your HuggingFace API key": "Gib deinen HuggingFace API-Schlüssel ein",
"Enter your OpenAI API key": "Geben Sie Ihren OpenAI API-Schlüssel ein",
"Enter your Replicate API key": "Geben Sie Ihren Replicate-API-Schlüssel ein",
"Entire Document": "Ganzes Dokument",
"err": "err",
"ERROR": "ERROR",
"EUR": "EUR",
"Exit Fullscreen": "Vollbildmodus beenden",
"Export": "Exportieren",
"Export JSON": "JSON exportieren",
"Export to CSV": "Exportieren nach CSV",
"Failed to generate content": "Inhalt konnte nicht generiert werden",
"Fast & Direct": "Schnell und direkt",
"Favorite Coils": "Bevorzugte Spulen",
"Favorite Registers": "Bevorzugte Register",
"Favorites": "Favoriten",
"Fields": "Felder",
"File": "Datei",
"File Browser": "Datei-Browser",
"File name": "Name der Datei",
"Fill": "Füllen Sie",
"Filling": "Füllen",
"Finish": "Oberfläche",
"Flush Cache": "Cache leeren",
"Flush System Cache": "System-Cache leeren",
"followers": "anhänger",
"following": "unter",
"For AI image generation (stored securely)": "Für die Erzeugung von AI-Bildern (sicher gespeichert)",
"For AIMLAPI services (stored securely)": "Für AIMLAPI-Dienste (sicher gespeichert)",
"For Bria AI services (stored securely)": "Für Bria AI-Dienste (sicher gespeichert)",
"For Google services (stored securely)": "Für Google-Dienste (sicher gespeichert)",
"For HuggingFace models (stored securely)": "Für HuggingFace-Modelle (sicher aufbewahrt)",
"For Replicate AI models (stored securely)": "Für Replicate AI-Modelle (sicher gespeichert)",
"Full Name": "Vollständiger Name",
"Fullscreen": "Vollbildschirm",
"Fullscreen Editor": "Vollbild-Editor",
"fwd": "fwd",
"Galerie": "Galerie",
"Gallery": "Galerie",
"General": "Allgemein",
"General Settings": "Allgemeine Einstellungen",
"Generate": "Erzeugen Sie",
"Generate AI Image": "AI-Bild generieren",
"Generate AI images or upload your own photos to share.": "Generieren Sie AI-Bilder oder laden Sie Ihre eigenen Fotos hoch, um sie mit anderen zu teilen.",
"Generate complete pages with AI-powered text and images.": "Generieren Sie komplette Seiten mit KI-gesteuerten Texten und Bildern.",
"Generate Image": "Bild generieren",
"Generate Text": "Text generieren",
"Generate Text Only": "Nur Text generieren",
"Generate Title & Description with AI": "Titel und Beschreibung mit AI generieren",
"Generate with AI": "Generieren mit AI",
"Generate with Images": "Mit Bildern generieren",
"Generate with Web Search": "Generieren mit Websuche",
"Generating content...": "Inhalte generieren...",
"Generating page content...": "Generierung von Seiteninhalten...",
"Global Settings": "Globale Einstellungen",
"HEX": "HEX",
"HMI Edit Mode Active": "HMI-Bearbeitungsmodus aktiv",
"Google API Key": "Google API-Schlüssel",
"Gracefully restart the server process. Systemd will automatically bring it back online. This will cause a brief downtime.": "Starten Sie den Serverprozess sanft neu. Systemd wird ihn automatisch wieder online bringen. Dies wird eine kurze Ausfallzeit verursachen.",
"Grant": "Grant",
"Grant Access": "Zugang gewähren",
"Grounding with Google Search": "Erdung mit Google-Suche",
"Hardware I/O": "Hardware-E/A",
"Heating Time": "Heizzeit",
"Help": "Hilfe",
"HEX": "HEX",
"Hidden": "Versteckt",
"HIDDEN": "HIDDEN",
"Hide page": "Seite ausblenden",
"Hierarchy": "Hierarchie",
"History": "Geschichte",
"HMI Edit Mode Active": "HMI-Bearbeitungsmodus aktiv",
"Home": "Startseite",
"HomingAuto": "HomingAuto",
"HomingMan": "HomingMan",
"Hostname": "Hostname",
"HTML Content": "HTML-Inhalt",
"https://…": "https://...",
"HuggingFace API Key": "HuggingFace API-Schlüssel",
"ID:": "ID:",
"IDLE": "IDLE",
"Idle": "Leerlauf",
"IDLE": "IDLE",
"Image Model": "Image-Modell",
"Image Tools": "Bild-Tools",
"Image Tools enabled: AI can generate and embed images in the content": "Bild-Tools aktiviert: AI kann Bilder erzeugen und in den Inhalt einbetten",
"Images": "Bilder",
"Images to help guide the AI generation": "Bilder als Orientierungshilfe für die KI-Generation",
"Import": "Importieren",
"Import JSON": "JSON importieren",
"in seconds": "in Sekunden",
"info": "infos",
"Info": "Infos",
"Injectors": "Spritzguss Maschienen",
"Insert": "einfügen.",
"Integrations": "Integrationen",
"Interactive gallery with main viewer and filmstrip navigation": "Interaktive Galerie mit Hauptbetrachter und Filmstreifennavigation",
"Interlocked": "Verriegelt",
"Internal notes, instructions…": "Interne Notizen, Anweisungen...",
"IP Address": "IP-Adresse",
"IP addresses that have been auto-banned for excessive requests": "IP-Adressen, die wegen übermäßiger Anfragen automatisch gesperrt wurden",
"items": "Artikel",
"Jammed": "Verklemmt",
"Jane Doe": "Unbekannte",
"jane@example.com": "jane@example.com",
"Joined": "Beitritt",
"Joined Date": "Datum des Beitritts",
"Joystick": "Joystick",
"LOADCELL": "LOADCELL",
"Keep Editing": "Weiter bearbeiten",
"Label": "Etikett",
"Language": "Sprache",
"Last updated": "Zuletzt aktualisiert",
"Last updated:": "Zuletzt aktualisiert:",
"Layout layout-43c94f2d-0b95-4db3-bdcc-74d9a2ee3a8e": "Layout layout-43c94f2d-0b95-4db3-bdcc-74d9a2ee3a8e",
"Layout Properties": "Layout-Eigenschaften",
"LAYOUTS": "LAYOUTS",
"like": "wie",
"likes": "mag",
"Live monitoring": "Live-Überwachung",
"LOADCELL": "LOADCELL",
"Loadcell[25]": "Kraftmesszelle[25]",
"Loadcell[26]": "Kraftmesszelle[26]",
"Loading addresses…": "Adressen laden...",
"Loading analytics...": "Analysen laden...",
"Loading Cassandra settings...": "Laden der Cassandra-Einstellungen...",
"Loading comments...": "Kommentare laden...",
"Loading Editor...": "Editor laden...",
"Loading gallery items...": "Galerieelemente laden...",
"Loading network settings...": "Laden der Netzwerkeinstellungen...",
"Loading page...": "Seite laden...",
"Loading pages...": "Seiten werden geladen...",
"Loading profile...": "Profil laden...",
"Loading profiles from Modbus...": "Profile von Modbus laden...",
"Loading users...": "Benutzer laden...",
"Loading vendor profiles…": "Herstellerprofile laden...",
"Loading versions...": "Versionen laden...",
"Loading...": "Laden...",
"Logs": "Protokolle",
"Logs Dashboard": "Logs Dashboard",
"Low": "Niedrig",
"Main": "Hauptseite",
"Make private": "Privat machen",
"Make Private": "Privat machen",
"Make public": "Öffentlich machen",
"Make Public": "Öffentlich machen",
"Make this picture visible to others": "Dieses Bild für andere sichtbar machen",
"Manage": "Verwalten Sie",
"Manage Categories": "Kategorien verwalten",
"Manage categories and organize your content structure.": "Verwalten Sie Kategorien und organisieren Sie Ihre Inhaltsstruktur.",
"Manage permissions for": "Verwalten von Berechtigungen für",
"Manage slave devices (max 1).": "Verwaltung von Slave-Geräten (max. 1).",
"Manage your account settings and preferences": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen",
"MANUAL": "MANUELL",
"MANUAL MULTI": "MANUELL MULTI",
"MANUAL_MULTI": "MANUELL_MULTI",
"MAXLOAD": "MAXLOAD",
"MAX_TIME": "MAX_TIME",
"MINLOAD": "MINLOAD",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"Manage slave devices (max 1).": "Verwaltung von Slave-Geräten (max. 1).",
"Markdown": "Markdown",
"Master Configuration": "Master-Konfiguration",
"Master Name": "Hauptname",
"Max": "Max",
"Max Simultaneous": "Max. gleichzeitige",
"MAX_TIME": "MAX_TIME",
"MAXLOAD": "MAXLOAD",
"Media": "Medien",
"Mid": "Mitte",
"Min": "Min",
"MINLOAD": "MINLOAD",
"Modbus": "Modbus",
"Mode": "Modus",
"Move control point down": "Kontrollpunkt nach unten verschieben",
"Move control point up": "Kontrollpunkt nach oben verschieben",
"MULTI_TIMEOUT": "MULTI_TIMEOUT",
"My Profile": "Mein Profil",
"My Purchases": "Meine Einkäufe",
"N/A": "K.A",
"NONE": "KEINE",
"Name": "Name",
"Nested Layout Container": "Verschachtelter Layout-Container",
"Network": "Netzwerk",
"Network Settings": "Netzwerk-Einstellungen",
"No Operation": "Keine Operation",
"New": "Neu",
"New Layout": "Neues Layout",
"New Page": "Neue Seite",
"New York": "New York",
"No active bans": "Keine aktiven Verbote",
"No active permissions found.": "Keine aktiven Berechtigungen gefunden.",
"No active violations": "Keine aktiven Verstöße",
"No coils data available. Try refreshing.": "Keine Coil-Daten verfügbar. Versuchen Sie zu aktualisieren.",
"No comments yet": "Noch keine Kommentare",
"No containers yet": "Noch keine Container",
"No enabled profile": "Kein aktiviertes Profil",
"No images selected": "Keine Bilder ausgewählt",
"No Operation": "Keine Operation",
"No other versions available for this image.": "Für dieses Bild sind keine anderen Versionen verfügbar.",
"No pictures available": "Keine Bilder vorhanden",
"No register data available. Try refreshing.": "Keine Registerdaten verfügbar. Versuchen Sie zu aktualisieren.",
"No source found.": "Keine Quelle gefunden.",
"No templates saved yet": "Noch keine Vorlagen gespeichert",
"No widgets found": "Keine Widgets gefunden",
"No widgets yet": "Noch keine Widgets",
"none": "keine",
"None": "Keine",
"NONE": "KEINE",
"Not in any collections": "Nicht in allen Sammlungen",
"Note: Image Tools require an OpenAI provider and will use your selected OpenAI model": "Hinweis: Image Tools erfordern einen OpenAI-Provider und verwenden das von Ihnen ausgewählte OpenAI-Modell.",
"Number": "Nummer",
"OC": "OC",
"OFFLINE": "OFFLINE",
"Offset": "Versetzt",
"OK": "OK",
"OL": "OL",
"ON": "ON",
"ONLINE": "ONLINE",
"OpenAI API Key": "OpenAI API-Schlüssel",
"Operatorswitch": "Operatorswitch",
"Optimize": "Optimieren Sie",
"Optimize prompt with AI": "Optimieren Sie die Eingabeaufforderung mit AI",
"Organizations": "Organisationen",
"Organize content into switchable tabs": "Organisieren von Inhalten in umschaltbaren Registerkarten",
"OV": "OV",
"OVERLOAD": "OVERLOAD",
"Offset": "Versetzt",
"Operatorswitch": "Operatorswitch",
"PID Control": "PID-Regelung",
"PV": "PV",
"PAGE": "SEITE",
"Page Card": "Seite Karte",
"Page created! Redirecting you now...": "Seite erstellt! Ich leite Sie jetzt um...",
"Page saved": "Gespeicherte Seite",
"Page Variables": "Variablen der Seite",
"Pages": "Seiten",
"Panels": "Paneele",
"Parent": "Elternteil",
"Partitions": "Partitionen",
"Password": "Passwort",
"Paste": "Kleister",
"Path": "Pfad",
"Pause": "Pause",
"Pause Profile": "Pause Profil",
"Permissions": "Berechtigungen",
"Phapp": "Phapp",
"Phone": "Telefon",
"Photo Card": "Fotokarte",
"Photo Grid": "Foto-Raster",
"Photo Grid Widget": "Foto-Gitter-Widget",
"pictures": "Bilder",
"PICTURES": "BILDER",
"PID Control": "PID-Regelung",
"Placeholder Text": "Platzhalter Text",
"Play from start": "Von Anfang an spielen",
"Playground": "Spielplatz",
"Plunge": "Eintauchen",
"Plunger": "Stößel",
"PlungingAuto": "EintauchenAuto",
"PlungingMan": "PlungingMan",
"PolyMech - Cassandra": "PolyMech - Cassandra",
"PolyMech - Cassandra": "PolyMech - Kassandra",
"Pop-out": "Pop-out",
"Post Comment": "Kommentar schreiben",
"PostFlow": "PostFlow",
"posts": "beiträge",
"POSTS": "POSTEN",
"Press": "Presse",
"Press Cylinder": "Presse-Zylinder",
"Press Cylinder Controls": "Pressenzylinder-Steuerung",
"Presscylinder": "Pressezylinder",
"Preview": "Vorschau",
"Priv": "Privat",
"private": "privat",
"Private": "Privat",
"Private layouts are only visible to you.": "Private Layouts sind nur für Sie sichtbar.",
"Product": "Produkt",
"Product-2": "Produkt-2",
"Profile": "Profil",
"Profile Curves": "Profil-Kurven",
"Profile Name": "Profil Name",
"Profile picture": "Profilbild",
"Profile Settings": "Profil-Einstellungen",
"Profile SP": "Profil SP",
"Profiles": "Profile",
"Prompt": "Eingabeaufforderung",
"Prompt Only": "Nur Aufforderung",
"Prompt Templates": "Prompt-Vorlagen",
"Properties": "Eigenschaften",
"Properties:": "Eigenschaften:",
"REMOTE": "FERNSEHEN",
"Pub": "Kneipe",
"public": "öffentlich",
"Public": "Öffentlich",
"Public layouts effectively become templates that are visible to everyone.": "Öffentliche Layouts werden praktisch zu Vorlagen, die für alle sichtbar sind.",
"Purchases": "Käufe",
"PV": "PV",
"Real time Charting": "Charting in Echtzeit",
"Real-time Charts": "Charts in Echtzeit",
"Recipient Email": "E-Mail des Empfängers",
"Record": "Datensatz",
"Record audio": "Audio aufnehmen",
"Redo": "Redo",
"Reference Images": "Referenzbilder",
"Refresh": "Aktualisieren",
"Refresh Rate": "Aktualisierungsrate",
"Registers": "Register",
"REMOTE": "FERNSEHEN",
"Remove all": "Alle entfernen",
"Remove all control points from this plot": "Alle Kontrollpunkte aus diesem Diagramm entfernen",
"Render HTML content with variable substitution": "Rendering von HTML-Inhalten mit variabler Substitution",
"Replace": "Ersetzen Sie",
"Replay": "Wiederholen Sie",
"Replicate API Key": "API-Schlüssel vervielfältigen",
"reset": "zurücksetzen",
"Reset": "Zurücksetzen",
"Reset Zoom": "Zoom zurücksetzen",
"reset_fault": "reset_fault",
"ResettingJam": "Zurücksetzen vonJam",
"Resolution": "Auflösung",
"Restart": "Neustart",
"Restart at end": "Neustart am Ende",
"Restart Server": "Server neu starten",
"rev": "rev",
"Root Category": "Kategorie \"Wurzel",
"run": "laufen",
"Run Action": "Aktion ausführen",
"Run this control point action now": "Führen Sie diese Kontrollpunktaktion jetzt aus",
"SP": "SP",
"SP CMD Addr:": "SP CMD Adr:",
"SP:": "SP:",
"STA Gateway": "STA-Gateway",
"STA IP Address": "STA IP-Adresse",
"STA Password": "STA-Passwort",
"STA Primary DNS": "STA Primäre DNS",
"STA SSID": "STA SSID",
"STA Secondary DNS": "STA Sekundärer DNS",
"STA Subnet Mask": "STA-Subnetzmaske",
"STALLED": "STALLED",
"sales@acme.com": "sales@acme.com",
"Samplesignalplot 0": "Mustersignalplot 0",
"Save AP Settings": "AP-Einstellungen speichern",
"Save": "Speichern Sie",
"Save All Settings": "Alle Einstellungen speichern",
"Save AP Settings": "AP-Einstellungen speichern",
"Save API Keys": "API-Schlüssel speichern",
"Save As": "Speichern unter",
"Save STA Settings": "STA-Einstellungen speichern",
"Save as New": "Als neu speichern",
"Save as Widget": "Als Widget speichern",
"Save Changes": "Änderungen speichern",
"Save current as template": "Aktuelles als Vorlage speichern",
"Save Signal Plot": "Signalplot speichern",
"Save STA Settings": "STA-Einstellungen speichern",
"Saved": "Gerettet",
"Saving...": "Sparen...",
"Scale": "Skala",
"Scale:": "Maßstab:",
"Search": "Suche",
"Search by title or ID...": "Suche nach Titel oder ID...",
"Search page or heading...": "Seite oder Rubrik durchsuchen...",
"Search pages...": "Seiten durchsuchen...",
"Search pictures, users, collections...": "Suche nach Bildern, Benutzern, Sammlungen...",
"Search...": "Suche...",
"Select Known Coil...": "Bekannte Spule auswählen...",
"Secret": "Geheimnis",
"Select a category to manage or link.": "Wählen Sie eine Kategorie zur Verwaltung oder Verknüpfung aus.",
"Select a control point to see its properties.": "Wählen Sie einen Kontrollpunkt aus, um seine Eigenschaften anzuzeigen.",
"Select a destination plot. The content of \"{plotName}\" will overwrite the selected plot. This action cannot be undone.": "Wählen Sie einen Zielplan aus. Der Inhalt von \"{PlotName}\" überschreibt den ausgewählten Plot. Diese Aktion kann nicht rückgängig gemacht werden.",
"Select a destination profile. The content of \"{profileName}\" will overwrite the selected profile. This action cannot be undone.": "Wählen Sie ein Zielprofil. Der Inhalt von \"{Profilname}\" wird das ausgewählte Profil überschreiben. Diese Aktion kann nicht rückgängig gemacht werden.",
@ -296,24 +620,48 @@
"Select a profile to overwrite": "Wählen Sie ein zu überschreibendes Profil",
"Select a register or coil address": "Wählen Sie ein Register oder eine Spulenadresse",
"Select a signal plot to associate and edit": "Wählen Sie ein Signaldiagramm zum Zuordnen und Bearbeiten aus",
"Select a tab": "Wählen Sie eine Registerkarte",
"Select a variable to configure": "Wählen Sie eine zu konfigurierende Variable",
"Select Known Coil...": "Bekannte Spule auswählen...",
"Select Picture": "Bild auswählen",
"Select Reference Images": "Referenzbilder auswählen",
"Select source...": "Quelle auswählen...",
"Select the AI model for image generation": "Wählen Sie das AI-Modell für die Bilderzeugung",
"Select type": "Typ auswählen",
"Selected child profiles will start, stop, pause, and resume with this parent profile.": "Ausgewählte untergeordnete Profile werden mit diesem übergeordneten Profil gestartet, gestoppt, angehalten und fortgesetzt.",
"Selected Images": "Ausgewählte Bilder",
"Selected Path": "Ausgewählter Pfad",
"Selected Text": "Ausgewählter Text",
"Selection": "Auswahl",
"Send": "Senden Sie",
"Send a password reset link to your email address": "Senden Sie einen Link zum Zurücksetzen des Passworts an Ihre E-Mail Adresse",
"Send Email Preview": "E-Mail-Vorschau senden",
"Send IFTTT Notification": "IFTTT-Benachrichtigung senden",
"Sending...": "Ich schicke...",
"Sequential Heating": "Sequentielle Heizung",
"Sequential Heating Control": "Sequentielle Heizungssteuerung",
"Series": "Serie",
"Series Toggles": "Serie Toggles",
"Series settings": "Einstellungen der Serie",
"Series Toggles": "Serie Toggles",
"Server": "Server",
"Server Management": "Server-Verwaltung",
"Set All": "Alle einstellen",
"Set All SP": "Alle SP einstellen",
"Set as Default": "Als Standard festlegen",
"Set as default address": "Als Standardadresse festlegen",
"Settings": "Einstellungen",
"Settings...": "Einstellungen...",
"setup": "einrichtung",
"Shipping Addresses": "Lieferadressen",
"Shortplot 70s": "Shortplot 70s",
"Show Legend": "Legende anzeigen",
"Show page": "Seite anzeigen",
"Show PV": "PV anzeigen",
"Show SP": "SP anzeigen",
"Shredders": "Granulatoren",
"Sidebar": "Seitenleiste",
"Sign in": "Eintragen",
"Sign out": "Abmelden",
"Signal Control Point Details": "Details zum Signalkontrollpunkt",
"Signal Plot Editor": "Signalplot-Editor",
"Signal plots configuration loaded from API.": "Konfiguration der Signaldiagramme von der API geladen.",
@ -325,28 +673,59 @@
"Slaves": "Sklaven",
"Slot": "Schlitz",
"Slot:": "Steckplatz:",
"Smart & Optimized": "Intelligent & Optimiert",
"Snippets": "Schnipsel",
"Source": "Quelle",
"SP": "SP",
"SP CMD Addr:": "SP CMD Adr:",
"SP:": "SP:",
"specs": "Spezifikationen",
"Specs Table": "Specs-Tabelle",
"Split": "Teilen",
"STA Gateway": "STA-Gateway",
"STA IP Address": "STA IP-Adresse",
"STA Password": "STA-Passwort",
"STA Primary DNS": "STA Primäre DNS",
"STA Secondary DNS": "STA Sekundärer DNS",
"STA SSID": "STA SSID",
"STA Subnet Mask": "STA-Subnetzmaske",
"STALLED": "STALLED",
"Start": "Start",
"Start Index": "Start-Index",
"Start PID Controllers": "PID-Regler starten",
"Start Profile": "Profil starten",
"State:": "Staat:",
"Station (STA) Mode": "Station (STA) Modus",
"stop": "stoppen",
"Stop": "Stopp",
"Stop PID Controllers": "PID-Regler anhalten",
"Stop Profile": "Profil anhalten",
"Stop and reset": "Anhalten und zurücksetzen",
"Stop at end": "Stopp am Ende",
"Stop PID Controllers": "PID-Regler anhalten",
"Stop Profile": "Profil anhalten",
"Stopped": "Gestoppt",
"Stopping": "Stoppen",
"Storage": "Lagerung",
"String": "Zeichenfolge",
"Structure": "Struktur",
"Subject": "Thema",
"Switch to edit mode to add containers": "In den Bearbeitungsmodus wechseln, um Container hinzuzufügen",
"Switch to edit mode to add widgets": "In den Bearbeitungsmodus wechseln, um Widgets hinzuzufügen",
"System Calls": "Systemaufrufe",
"System Control": "Systemsteuerung",
"System Information": "System-Informationen",
"System Messages": "System-Meldungen",
"Table of Contents": "Inhaltsübersicht",
"Tabs Widget": "Registerkarten-Widget",
"Target Controllers (Registers)": "Ziel-Controller (Register)",
"Tell us about yourself...": "Erzählen Sie uns von sich...",
"Temperature Control Points": "Temperaturkontrollpunkte",
"Temperature Profiles": "Temperatur-Profile",
"Template Name": "Vorlage Name",
"Templates": "Schablonen",
"Text Block": "Textblock",
"Text shown when content is empty": "Text wird angezeigt, wenn der Inhalt leer ist",
"Textblock": "Textblock",
"The system tracks rate limit violations for IPs and authenticated users.": "Das System verfolgt Ratenüberschreitungen für IPs und authentifizierte Benutzer.",
"This hostname is used for both STA and AP modes. Changes here will be saved with either form.": "Dieser Hostname wird sowohl für den STA- als auch für den AP-Modus verwendet. Änderungen hier werden in beiden Formen gespeichert.",
"This is where you'll design and configure your HMI layouts.": "Hier werden Sie Ihre HMI-Layouts entwerfen und konfigurieren.",
"This will permanently clear the profile \"{profileName}\" from the server. This action cannot be undone.": "Dadurch wird das Profil \"{Profilname}\" dauerhaft vom Server gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
@ -356,103 +735,71 @@
"Total": "Insgesamt",
"Total Cost": "Gesamtkosten",
"Total:": "Insgesamt:",
"Type": "Typ",
"Type:": "Art:",
"Types": "Typen",
"Unban": "Unban",
"Undo": "Rückgängig machen",
"United States": "Vereinigte Staaten",
"Unknown": "Unbekannt",
"Unused / Orphaned Pictures": "Unbenutzte / verwaiste Bilder",
"Update": "Update",
"Update Layout": "Layout aktualisieren",
"Update Profile": "Profil aktualisieren",
"Updates every 5 seconds": "Aktualisierung alle 5 Sekunden",
"Upload": "Hochladen",
"Upload All JSON": "Alle JSON hochladen",
"Upload Image": "Bild hochladen",
"Upload Images": "Bilder hochladen",
"Upload images or select from gallery": "Bilder hochladen oder aus der Galerie auswählen",
"Upload JSON for {name}": "JSON für {Name} hochladen",
"Upload Plot": "Plot hochladen",
"Upload Video": "Video hochladen",
"Upload videos to share with the community. Automatic processing and optimization.": "Laden Sie Videos hoch, um sie mit der Community zu teilen. Automatische Verarbeitung und Optimierung.",
"Use Google Search to improve image relevance and accuracy.": "Nutzen Sie die Google-Suche, um die Relevanz und Genauigkeit von Bildern zu verbessern.",
"User": "Benutzer",
"User Defined": "Benutzerdefiniert",
"User Management": "Benutzerverwaltung",
"User not found": "Benutzer nicht gefunden",
"Username": "Benutzername",
"Users": "Benutzer",
"Value:": "Wert:",
"Variables": "Variablen",
"Vendor Profiles": "Anbieter-Profile",
"Versions": "Versionen",
"Videos": "Videos",
"View": "Siehe",
"VIEW": "VIEW",
"View mode: Interact with your widgets": "Ansichtsmodus: Interaktion mit Ihren Widgets",
"Violation Monitor": "Verstoßmonitor",
"Violation records are automatically cleaned up after the time window expires.": "Die Aufzeichnungen über Verstöße werden nach Ablauf des Zeitfensters automatisch bereinigt.",
"Violation Tracking:": "Verfolgung von Verstößen:",
"Violations": "Verstöße",
"Visibility": "Sichtbarkeit",
"Visible": "Sichtbar",
"Visible Controllers": "Sichtbare Kontrolleure",
"Voice": "Stimme",
"Voice + AI": "Sprache + KI",
"Watched Items": "Beobachtete Artikel",
"Web Search": "Web-Suche",
"Web Search enabled: AI can search the web for up-to-date information": "Websuche aktiviert: KI kann im Internet nach aktuellen Informationen suchen",
"Welcome to the admin dashboard. More features coming soon!": "Willkommen auf dem Admin-Dashboard. Mehr Funktionen in Kürze!",
"What would you like to create?": "Was würden Sie gerne schaffen?",
"When an entity reaches 5 violations within the configured time window, they are automatically banned and moved to the ban list.": "Wenn eine Entität 5 Verstöße innerhalb des konfigurierten Zeitfensters erreicht, wird sie automatisch gesperrt und auf die Sperrliste gesetzt.",
"When Slave Mode is enabled, all Omron controllers will be disabled for processing.": "Wenn der Slave-Modus aktiviert ist, werden alle Omron-Regler für die Verarbeitung deaktiviert.",
"Widget editor and drag-and-drop functionality coming soon...": "Widget-Editor und Drag-and-Drop-Funktionalität in Kürze...",
"Widgets": "Widgets",
"WIDGETS": "WIDGETS",
"Window (min)": "Fenster (min)",
"Window Offset": "Fenster Versatz",
"Workflows": "Arbeitsabläufe",
"Write Coil": "Spule schreiben",
"Write GPIO": "GPIO schreiben",
"Write Holding Register": "Schreib-Halte-Register",
"X-Axis": "X-Achse",
"Y-Axis Left": "Y-Achse links",
"accel": "beschleunigung",
"decel": "abbremsen",
"e.g., Start Heating": "z.B. Start Heizung",
"e.g., Turn on coil for pre-heating stage": "z.B., Einschalten der Spule für die Vorwärmstufe",
"err": "err",
"fwd": "fwd",
"in seconds": "in Sekunden",
"info": "infos",
"none": "keine",
"reset": "zurücksetzen",
"reset_fault": "reset_fault",
"rev": "rev",
"run": "laufen",
"setup": "einrichtung",
"stop": "stoppen",
"Loading comments...": "Kommentare laden...",
"Edit with AI Wizard": "Bearbeiten mit AI Wizard",
"Be the first to like this": "Sei der Erste, dem dies gefällt",
"Versions": "Versionen",
"Current": "Aktuell",
"Add a comment...": "Einen Kommentar hinzufügen...",
"Post Comment": "Kommentar schreiben",
"No comments yet": "Noch keine Kommentare",
"Be the first to comment!": "Seien Sie der Erste, der einen Kommentar abgibt!",
"Save": "Speichern Sie",
"likes": "mag",
"like": "wie",
"Prompt Templates": "Prompt-Vorlagen",
"Optimize prompt with AI": "Optimieren Sie die Eingabeaufforderung mit AI",
"Describe the image you want to create or edit... (Ctrl+V to paste images)": "Beschreiben Sie das Bild, das Sie erstellen oder bearbeiten möchten... (Strg+V zum Einfügen von Bildern)",
"e.g. Cyberpunk Portrait": "z.B. Cyberpunk Portrait",
"Prompt": "Eingabeaufforderung",
"Templates": "Schablonen",
"Optimize": "Optimieren Sie",
"Selected Images": "Ausgewählte Bilder",
"Upload Images": "Bilder hochladen",
"Choose Files": "Dateien auswählen",
"No images selected": "Keine Bilder ausgewählt",
"Upload images or select from gallery": "Bilder hochladen oder aus der Galerie auswählen",
"No templates saved yet": "Noch keine Vorlagen gespeichert",
"Save current as template": "Aktuelles als Vorlage speichern",
"Loading profile...": "Profil laden...",
"Back to feed": "Zurück zu Futtermittel",
"Create Post": "Beitrag erstellen",
"posts": "beiträge",
"followers": "anhänger",
"following": "unter",
"Joined": "Beitritt",
"Collections": "Sammlungen",
"New": "Neu",
"POSTS": "POSTEN",
"HIDDEN": "HIDDEN",
"Profile picture": "Profilbild",
"your.email@example.com": "your.email@example.com",
"Enter username": "Benutzernamen eingeben",
"Enter display name": "Anzeigename eingeben",
"Tell us about yourself...": "Erzählen Sie uns von sich...",
"Change Avatar": "Avatar ändern",
"Email": "E-Mail",
"Username": "Benutzername",
"Display Name": "Name anzeigen",
"Bio": "Bio",
"You have unsaved changes. Are you sure you want to discard them and exit?": "Sie haben ungespeicherte Änderungen. Sind Sie sicher, dass Sie sie verwerfen und beenden möchten?",
"Your preferred language for the interface": "Ihre bevorzugte Sprache für die Schnittstelle",
"Save Changes": "Änderungen speichern",
"Edit Picture": "Bild bearbeiten",
"Edit Details": "Details bearbeiten",
"Generate Title & Description with AI": "Titel und Beschreibung mit AI generieren",
"Enter a title...": "Geben Sie einen Titel ein...",
"Record audio": "Audio aufnehmen",
"Describe your photo... You can use **markdown** formatting!": "Beschreiben Sie Ihr Foto... Sie können **Markdown** Formatierung verwenden!",
"Description (Optional)": "Beschreibung (fakultativ)",
"Visible": "Sichtbar",
"Make this picture visible to others": "Dieses Bild für andere sichtbar machen",
"Update": "Update",
"Loading versions...": "Versionen laden...",
"No other versions available for this image.": "Für dieses Bild sind keine anderen Versionen verfügbar."
}
"your.email@example.com": "your.email@example.com",
"ZIP / Postal Code": "ZIP / Postleitzahl"
}

View File

@ -0,0 +1,146 @@
{
"A widget that contains its own independent layout canvas.": "包含独立布局画布的部件。",
"Actions": "行动",
"Add Child": "添加儿童",
"Add rich text content with Markdown support": "通过 Markdown 支持添加富文本内容",
"Add to Cart": "添加到购物车",
"ADVANCED": "高级",
"AI Image Generator": "AI 图像生成器",
"AI Layout": "人工智能布局",
"All": "全部",
"All langs": "所有语言",
"All Page Translations": "所有页面翻译",
"All types": "所有类型",
"Arbor injection machine that provides fast, repeatable, comfortable, safe and precise injection of plastic!\n\n#### \"Highlights and details\"\n\n* All parts are precision manufactured, using modern CNC and manual machines\n* Smooth and precise plunging experience\n* The plunger has a replaceable bronze tip\n* 2 mold interfaces: cone for press, and M20 thread interface\n* Heat-shield and insulation\n* Step-less mould height adjustment\n* Mould guide pins and slots\n* Transmission 1:1\n* Shot size: 145G\n* Quick Clamping\n* Shutoff valve\n* Mostly Stainless, Aluminum\n\n#### Services\n\n* After - Sales Service\n* 3 years Warranty\n* Cheap replacements for consumables\n* Customization to user needs\n* Mold design and fabrication": "Arbor 注塑机可快速、可重复、舒适、安全、精确地注塑塑料!\n\n#### \"亮点和细节\"\n\n* 所有部件均采用现代 CNC 和手动机器精密制造而成\n* 平滑、精确的柱塞体验\n* 柱塞具有可更换的青铜顶端\n* 2 个模具接口:用于冲压的锥形接口和 M20 螺纹接口\n* 隔热罩和隔热材料\n* 无级模具高度调节\n* 模具导向销和槽\n* 传动比 1:1\n* 铸件尺寸145G\n* 快速夹紧\n* 截止阀\n* 大部分为不锈钢、铝质\n\n#### 服务\n\n* 售后服务\n* 3 年保修\n* 廉价的耗材更换\n* 根据用户需求定制\n* 模具设计和制造",
"Browse files and directories on VFS mounts": "浏览 VFS 挂载上的文件和目录",
"Cancel": "取消",
"Categories": "类别",
"Clipboard": "剪贴板",
"Configure": "配置",
"Containers": "集装箱",
"Copy": "复制",
"Data": "数据",
"Delete": "删除",
"DESIGN": "设计",
"Developer": "开发人员",
"Discard": "丢弃",
"Discard & Exit": "丢弃和退出",
"Discard changes?": "放弃更改?",
"Display a customizable grid of selected photos": "显示所选照片的自定义网格",
"Display a single page card with details": "显示包含详细信息的单页卡片",
"Display a single photo card with details": "显示包含详细信息的单张照片卡",
"Display photos in a responsive grid layout": "以响应式网格布局显示照片",
"Dump JSON": "转储 JSON",
"Edit": "编辑",
"Edit Tags": "编辑标签",
"Edit Translations": "编辑翻译",
"Edit with AI Wizard": "使用 AI 向导进行编辑",
"Email": "电子邮件",
"Email sent successfully!": "电子邮件已成功发送!",
"Entity ID": "实体 ID",
"Entity type": "实体类型",
"entries": "参赛",
"Export": "出口",
"Fields": "字段",
"File": "文件",
"File Browser": "文件浏览器",
"Finish": "完成",
"Gallery": "画廊",
"Glossary": "术语表",
"Hierarchy": "层次结构",
"History": "历史",
"HTML Content": "HTML 内容",
"I18N": "I18N",
"Import": "进口",
"Interactive gallery with main viewer and filmstrip navigation": "交互式画廊,带主浏览器和影片导航功能",
"items": "项目",
"Keep Editing": "继续编辑",
"Key": "钥匙",
"Language": "语言",
"Last updated": "最后更新",
"Last updated:": "最后更新",
"Latest": "最新",
"LAYOUTS": "布局",
"Loading Editor...": "加载编辑器...",
"Loading gallery items...": "正在加载图库项目...",
"Loading page...": "正在载入页面...",
"Logs Dashboard": "日志仪表板",
"Main": "主页",
"Manage": "管理",
"Manage Categories": "管理类别",
"Merge translations into i18n files": "将翻译合并到 i18n 文件中",
"missing": "失踪",
"My Profile": "我的简介",
"Nested Layout Container": "嵌套布局容器",
"New": "新",
"No glossary": "无术语表",
"No widget selected": "未选择部件",
"No widget translations found.": "未找到小部件翻译。",
"Open": "开放",
"Open in full page": "打开全页",
"Organize content into switchable tabs": "将内容组织到可切换的标签中",
"Page": "页次",
"PAGE": "页码",
"Page Card": "页卡",
"Page made private": "页面变为非公开页面",
"Page Meta": "页面元",
"Page Translations": "页面翻译",
"Parent": "家长",
"Paste": "粘贴",
"Photo Card": "照片卡",
"Photo Grid": "照片网格",
"Photo Grid Widget": "照片网格小工具",
"Preview": "预览",
"Priv": "私人",
"Private": "私人",
"Pub": "出版社",
"Public": "公众",
"Re-translate": "重新翻译",
"Recipient Email": "收件人电子邮件",
"Redo": "重做",
"Render HTML content with variable substitution": "使用变量替换渲染 HTML 内容",
"Save": "节省",
"Save to DB": "保存到数据库",
"Save translated entries to database": "将翻译条目保存到数据库",
"Save Translation": "节省翻译",
"Saved": "已保存",
"Search": "搜索",
"Search page or heading...": "搜索页面或标题...",
"Search pages...": "搜索页面...",
"Search pictures, users, collections...": "搜索图片、用户、收藏...",
"Search source or translation...": "搜索来源或翻译...",
"Select a tab": "选择标签",
"Selected Widget": "选定小工具",
"Selection": "选择",
"Send": "发送",
"Send Email Preview": "发送电子邮件预览",
"Sending...": "发送...",
"Show missing": "显示丢失",
"Showing missing only": "仅显示缺失",
"Sign in": "登录",
"Source": "资料来源",
"Status": "现状",
"Table of Contents": "目录",
"Tabs Widget": "标签小工具",
"Target lang": "目标语言",
"Template Name": "模板名称",
"Test Page": "测试页面",
"Text Block": "文本块",
"Title": "标题",
"Top": "返回顶部",
"Translate": "翻译",
"Translation": "翻译",
"Translation saved": "翻译已保存",
"translations to database": "翻译到数据库",
"Types": "类型",
"Undo": "撤消",
"Update": "更新",
"Update i18n": "更新 i18n",
"Variables": "变量",
"VIEW": "查看",
"Visible": "可见",
"Widget ID": "小工具 ID",
"Widgets": "小工具",
"WIDGETS": "万维网",
"You have unsaved changes. Are you sure you want to discard them and exit?": "您有未保存的更改。您确定要放弃这些更改并退出吗?"
}

View File

@ -37,6 +37,8 @@ const KEY_PREFIXES: [RegExp, (...groups: string[]) => string[]][] = [
[/^types-(.+)$/, (_, rest) => ['types', rest]],
[/^i18n-(.+)$/, (_, rest) => ['i18n', rest]],
[/^acl-(.+?)-(.+)$/, (_, type, id) => ['acl', type, id]],
[/^layout-(.+)$/, (_, id) => ['layouts', id]],
[/^layouts-(.+)$/, (_, rest) => ['layouts', rest]],
];
export const parseQueryKey = (key: string): string[] => {

View File

@ -1,4 +1,5 @@
import { widgetRegistry } from './widgetRegistry';
import { translate } from '@/i18n';
import {
Monitor,
Layout,
@ -15,7 +16,7 @@ import type {
PageCardWidgetProps,
MarkdownTextWidgetProps,
LayoutContainerWidgetProps,
FileBrowserWidgetProps
FileBrowserWidgetProps,
} from '@polymech/shared';
import PageCardWidget from '@/modules/pages/PageCardWidget';
@ -29,6 +30,7 @@ import GalleryWidget from '@/components/widgets/GalleryWidget';
import TabsWidget from '@/components/widgets/TabsWidget';
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
import { FileBrowserWidget } from '@/modules/storage';
import HomeWidget from '@/components/widgets/HomeWidget';
export function registerAllWidgets() {
// Clear existing registrations (useful for HMR)
@ -39,9 +41,9 @@ export function registerAllWidgets() {
component: HtmlWidget,
metadata: {
id: 'html-widget',
name: 'HTML Content',
name: translate('HTML Content'),
category: 'display',
description: 'Render HTML content with variable substitution',
description: translate('Render HTML content with variable substitution'),
icon: Code,
defaultProps: {
content: '<div>\n <h3 class="text-xl font-bold">Hello ${name}</h3>\n <p>Welcome to our custom widget!</p>\n</div>',
@ -79,9 +81,9 @@ export function registerAllWidgets() {
component: PhotoGrid,
metadata: {
id: 'photo-grid',
name: 'Photo Grid',
name: translate('Photo Grid'),
category: 'custom',
description: 'Display photos in a responsive grid layout',
description: translate('Display photos in a responsive grid layout'),
icon: Monitor,
defaultProps: {
variables: {}
@ -98,14 +100,18 @@ export function registerAllWidgets() {
component: PhotoCardWidget,
metadata: {
id: 'photo-card',
name: 'Photo Card',
name: translate('Photo Card'),
category: 'custom',
description: 'Display a single photo card with details',
description: translate('Display a single photo card with details'),
icon: Monitor,
defaultProps: {
pictureId: null,
showHeader: true,
showFooter: true,
showAuthor: true,
showActions: true,
showTitle: true,
showDescription: true,
contentDisplay: 'below',
imageFit: 'contain',
variables: {}
@ -129,6 +135,30 @@ export function registerAllWidgets() {
description: 'Show footer with likes, comments, and actions',
default: true
},
showAuthor: {
type: 'boolean',
label: 'Include Author',
description: 'Show author avatar and name',
default: true
},
showActions: {
type: 'boolean',
label: 'Include Actions',
description: 'Show like, comment, download buttons',
default: true
},
showTitle: {
type: 'boolean',
label: 'Show Title',
description: 'Display the picture title',
default: true
},
showDescription: {
type: 'boolean',
label: 'Show Description',
description: 'Display the picture description',
default: true
},
contentDisplay: {
type: 'select',
label: 'Content Display',
@ -161,9 +191,9 @@ export function registerAllWidgets() {
component: PhotoGridWidget,
metadata: {
id: 'photo-grid-widget',
name: 'Photo Grid Widget',
name: translate('Photo Grid Widget'),
category: 'custom',
description: 'Display a customizable grid of selected photos',
description: translate('Display a customizable grid of selected photos'),
icon: Monitor,
defaultProps: {
pictureIds: [],
@ -187,9 +217,9 @@ export function registerAllWidgets() {
component: TabsWidget,
metadata: {
id: 'tabs-widget',
name: 'Tabs Widget',
name: translate('Tabs Widget'),
category: 'layout',
description: 'Organize content into switchable tabs',
description: translate('Organize content into switchable tabs'),
icon: Layout,
defaultProps: {
tabs: [
@ -277,9 +307,9 @@ export function registerAllWidgets() {
component: GalleryWidget,
metadata: {
id: 'gallery-widget',
name: 'Gallery',
name: translate('Gallery'),
category: 'custom',
description: 'Interactive gallery with main viewer and filmstrip navigation',
description: translate('Interactive gallery with main viewer and filmstrip navigation'),
icon: Monitor,
defaultProps: {
pictureIds: [],
@ -364,9 +394,9 @@ export function registerAllWidgets() {
component: PageCardWidget,
metadata: {
id: 'page-card',
name: 'Page Card',
name: translate('Page Card'),
category: 'custom',
description: 'Display a single page card with details',
description: translate('Display a single page card with details'),
icon: FileText,
defaultProps: {
pageId: null,
@ -417,9 +447,9 @@ export function registerAllWidgets() {
component: MarkdownTextWidget,
metadata: {
id: 'markdown-text',
name: 'Text Block',
name: translate('Text Block'),
category: 'display',
description: 'Add rich text content with Markdown support',
description: translate('Add rich text content with Markdown support'),
icon: FileText,
defaultProps: {
content: '',
@ -444,9 +474,9 @@ export function registerAllWidgets() {
component: LayoutContainerWidget,
metadata: {
id: 'layout-container-widget',
name: 'Nested Layout Container',
name: translate('Nested Layout Container'),
category: 'custom',
description: 'A widget that contains its own independent layout canvas.',
description: translate('A widget that contains its own independent layout canvas.'),
icon: Layout,
defaultProps: {
nestedPageName: 'Nested Container',
@ -488,9 +518,9 @@ export function registerAllWidgets() {
component: FileBrowserWidget,
metadata: {
id: 'file-browser',
name: 'File Browser',
name: translate('File Browser'),
category: 'custom',
description: 'Browse files and directories on VFS mounts',
description: translate('Browse files and directories on VFS mounts'),
icon: Monitor,
defaultProps: {
mount: 'root',
@ -603,4 +633,82 @@ export function registerAllWidgets() {
}
});
// Home Widget
widgetRegistry.register({
component: HomeWidget,
metadata: {
id: 'home',
name: translate('Home Feed'),
category: 'display',
description: translate('Display the main home feed with photos, categories, and sorting'),
icon: Monitor,
defaultProps: {
sortBy: 'latest',
viewMode: 'grid',
showCategories: false,
categorySlugs: '',
userId: '',
showSortBar: true,
showFooter: true,
variables: {}
},
configSchema: {
sortBy: {
type: 'select',
label: 'Sort By',
description: 'Default sort order for the feed',
options: [
{ value: 'latest', label: 'Latest' },
{ value: 'top', label: 'Top' }
],
default: 'latest'
},
viewMode: {
type: 'select',
label: 'View Mode',
description: 'Default display mode for the feed',
options: [
{ value: 'grid', label: 'Grid' },
{ value: 'large', label: 'Large Gallery' },
{ value: 'list', label: 'List' }
],
default: 'grid'
},
showCategories: {
type: 'boolean',
label: 'Show Categories Sidebar',
description: 'Show the category tree sidebar on desktop',
default: false
},
categorySlugs: {
type: 'text',
label: 'Category Filter',
description: 'Comma-separated category slugs to filter by (leave empty for all)',
default: ''
},
userId: {
type: 'userPicker',
label: 'User Filter',
description: 'Filter feed to show only content from a specific user',
default: ''
},
showSortBar: {
type: 'boolean',
label: 'Show Sort Bar',
description: 'Show the sort and category toggle bar',
default: true
},
showFooter: {
type: 'boolean',
label: 'Show Footer',
description: 'Show the site footer below the feed',
default: true
}
},
minSize: { width: 400, height: 400 },
resizable: true,
tags: ['home', 'feed', 'gallery', 'photos', 'categories']
}
});
}

View File

@ -1,7 +1,7 @@
import { WidgetType } from '@polymech/shared';
export interface WidgetMetadata<P = Record<string, any>> {
id: WidgetType;
id: WidgetType | (string & {});
name: string;
category: 'control' | 'display' | 'chart' | 'system' | 'custom' | string;
description: string;

View File

@ -1,4 +1,5 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { getCurrentLang } from '@/i18n';
// --- Category Management ---
export interface Category {
@ -18,7 +19,7 @@ export interface Category {
children?: { child: Category }[];
}
export const fetchCategories = async (options?: { parentSlug?: string; includeChildren?: boolean }): Promise<Category[]> => {
export const fetchCategories = async (options?: { parentSlug?: string; includeChildren?: boolean; sourceLang?: string }): Promise<Category[]> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
@ -28,6 +29,13 @@ export const fetchCategories = async (options?: { parentSlug?: string; includeCh
if (options?.parentSlug) params.append('parentSlug', options.parentSlug);
if (options?.includeChildren) params.append('includeChildren', String(options.includeChildren));
// Pass language for translated category names/descriptions
// Check URL ?lang= param first, then getCurrentLang()
const urlLang = new URLSearchParams(window.location.search).get('lang');
const lang = urlLang || getCurrentLang();
const srcLang = options?.sourceLang || 'en';
if (lang && lang !== srcLang) params.append('lang', lang);
const res = await fetch(`/api/categories?${params.toString()}`, { headers });
if (!res.ok) throw new Error(`Failed to fetch categories: ${res.statusText}`);
return await res.json();

View File

@ -0,0 +1,61 @@
import React from 'react';
import { Languages } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { T, translate } from '@/i18n';
import { type Glossary } from './client-i18n';
import { TranslationsGrid, type TranslatableField } from './TranslationsGrid';
export interface CategoryTranslationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
categoryId: string;
categoryName: string;
categoryDescription?: string;
sourceLang?: string;
glossaries?: Glossary[];
}
export const CategoryTranslationDialog: React.FC<CategoryTranslationDialogProps> = ({
open,
onOpenChange,
categoryId,
categoryName,
categoryDescription,
sourceLang,
glossaries,
}) => {
const fields: TranslatableField[] = [];
if (categoryName) {
fields.push({ label: translate('Name'), propPath: 'name', sourceText: categoryName });
}
if (categoryDescription) {
fields.push({ label: translate('Description'), propPath: 'description', sourceText: categoryDescription, multiline: true });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] max-w-[90vw] h-[90vh] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-2 shrink-0">
<DialogTitle className="flex items-center gap-2">
<Languages className="h-5 w-5 text-blue-500" />
<T>Category Translations</T>
<span className="text-sm font-normal text-muted-foreground">
{categoryName}
</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6">
<TranslationsGrid
entityType="category"
entityId={categoryId}
fields={fields}
sourceLang={sourceLang}
glossaries={glossaries}
/>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,473 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader2, Languages, Save, X, Check } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { T, translate, getCurrentLang } from '@/i18n';
import {
type Glossary,
TargetLanguageCodeSchema,
upsertWidgetTranslation,
fetchGlossaries,
} from './client-i18n';
import { WidgetTranslationPanel } from './WidgetTranslationPanel';
import { type WidgetTranslationFilter } from './useWidgetTranslation';
import { useWidgetTranslation } from './useWidgetTranslation';
import { toast } from 'sonner';
import { useAppConfig } from '@/hooks/useSystemInfo';
const TARGET_LANGS = [
...TargetLanguageCodeSchema.options[0].options,
...TargetLanguageCodeSchema.options[1].options,
].sort();
interface FieldTranslation {
translated: string | null;
saving: boolean;
}
export interface PageTranslationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pageId: string;
/** The selected widget's instance ID within the page layout */
widgetInstanceId?: string | null;
/** The widget type id (e.g. 'markdown-text') */
widgetTypeId?: string;
/** The widget's source content (for quick translate) */
sourceContent?: string;
/** Page title for meta-field translation */
pageTitle?: string;
/** Source language */
sourceLang?: string;
/** External glossaries (if not provided, will be auto-loaded) */
glossaries?: Glossary[];
}
export const PageTranslationDialog: React.FC<PageTranslationDialogProps> = ({
open,
onOpenChange,
pageId,
widgetInstanceId,
widgetTypeId,
sourceContent,
pageTitle,
sourceLang: initialSourceLangProp,
glossaries: externalGlossaries,
}) => {
const appConfig = useAppConfig();
const srcLangDefault = appConfig?.i18n?.source_language || 'en';
const initialSourceLang = initialSourceLangProp || srcLangDefault;
const [srcLang, setSrcLang] = useState<string>(initialSourceLang);
const [targetLang, setTargetLang] = useState<string>(() => {
const cur = getCurrentLang();
return cur === initialSourceLang ? 'de' : cur;
});
const [translating, setTranslating] = useState(false);
const [saving, setSaving] = useState(false);
const [translatedPreview, setTranslatedPreview] = useState<string | null>(null);
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
const [glossaries, setGlossaries] = useState<Glossary[]>(externalGlossaries || []);
const [selectedGlossaryId, setSelectedGlossaryId] = useState<string>('none');
// Meta field translation state
const [titlePreview, setTitlePreview] = useState<FieldTranslation>({ translated: null, saving: false });
// Auto-load glossaries if not provided externally
useEffect(() => {
if (!externalGlossaries) {
fetchGlossaries().then(setGlossaries).catch(() => { });
}
}, [externalGlossaries]);
const wt = useWidgetTranslation({
defaultSourceLang: srcLang,
defaultTargetLang: targetLang,
});
const filter = useMemo<WidgetTranslationFilter>(() => ({
entityType: 'page',
entityId: pageId,
...(widgetInstanceId ? { widgetId: widgetInstanceId } : {}),
}), [pageId, widgetInstanceId]);
// Step 1: Translate only — show preview
const handleQuickTranslate = async () => {
if (!sourceContent) {
toast.error(translate('No content to translate'));
return;
}
setTranslating(true);
try {
const gid = selectedGlossaryId !== 'none' ? selectedGlossaryId : undefined;
const result = await wt.translateSingle(sourceContent, srcLang, targetLang, gid);
if (result) {
setTranslatedPreview(result);
}
} finally {
setTranslating(false);
}
};
// Step 2: Save the previewed translation
const handleSaveTranslation = async () => {
if (!translatedPreview || !widgetInstanceId) return;
setSaving(true);
try {
await upsertWidgetTranslation({
entity_type: 'page',
entity_id: pageId,
widget_id: widgetInstanceId,
prop_path: 'content',
source_lang: srcLang,
target_lang: targetLang,
source_text: sourceContent || '',
translated_text: translatedPreview,
status: 'machine',
});
toast.success(translate('Translation saved'));
setTranslatedPreview(null);
// Trigger panel reload
setPanelRefreshKey(k => k + 1);
} catch (e: any) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const handleDiscardPreview = () => {
setTranslatedPreview(null);
};
// --- Meta field handlers (title, etc.) ---
const handleTranslateMetaField = async (
sourceText: string,
setter: React.Dispatch<React.SetStateAction<FieldTranslation>>
) => {
if (!sourceText) {
toast.error(translate('No content to translate'));
return;
}
setTranslating(true);
try {
const gid = selectedGlossaryId !== 'none' ? selectedGlossaryId : undefined;
const result = await wt.translateSingle(sourceText, srcLang, targetLang, gid);
if (result) {
setter({ translated: result, saving: false });
}
} finally {
setTranslating(false);
}
};
const handleSaveMetaField = async (
propPath: string,
sourceText: string,
translatedText: string,
setter: React.Dispatch<React.SetStateAction<FieldTranslation>>
) => {
setter(prev => ({ ...prev, saving: true }));
try {
await upsertWidgetTranslation({
entity_type: 'page',
entity_id: pageId,
widget_id: '__meta__',
prop_path: propPath,
source_lang: srcLang,
target_lang: targetLang,
source_text: sourceText,
translated_text: translatedText,
status: 'machine',
});
toast.success(translate('Translation saved'));
setter({ translated: null, saving: false });
setPanelRefreshKey(k => k + 1);
} catch (e: any) {
toast.error(e.message);
setter(prev => ({ ...prev, saving: false }));
}
};
const renderMetaFieldRow = (
label: string,
propPath: string,
sourceText: string,
preview: FieldTranslation,
setPreview: React.Dispatch<React.SetStateAction<FieldTranslation>>,
) => (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground w-24 shrink-0">{label}</span>
<span className="text-xs text-muted-foreground truncate flex-1">{sourceText.slice(0, 120)}{sourceText.length > 120 ? '…' : ''}</span>
{preview.translated === null && (
<Button
size="sm"
variant="outline"
onClick={() => handleTranslateMetaField(sourceText, setPreview)}
disabled={translating || !sourceText}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30 shrink-0"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Translate</T>
</Button>
)}
</div>
{preview.translated !== null && (
<div className="space-y-1.5 ml-0">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase bg-primary/10 text-primary px-1.5 py-0.5 rounded">{targetLang}</span>
</div>
<Input
value={preview.translated}
onChange={e => setPreview(prev => ({ ...prev, translated: e.target.value }))}
className="text-sm font-medium"
/>
<div className="flex items-center gap-2 justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => setPreview({ translated: null, saving: false })}
className="text-xs"
>
<X className="h-3.5 w-3.5 mr-1" />
<T>Discard</T>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleTranslateMetaField(sourceText, setPreview)}
disabled={translating}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Re-translate</T>
</Button>
<Button
size="sm"
onClick={() => handleSaveMetaField(propPath, sourceText, preview.translated!, setPreview)}
disabled={preview.saving || !(preview.translated || '').trim()}
className="text-xs"
>
{preview.saving ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
<T>Save Translation</T>
</Button>
</div>
</div>
)}
</div>
);
const handleSrcLangChange = (lang: string) => {
setSrcLang(lang);
setTranslatedPreview(null);
setTitlePreview({ translated: null, saving: false });
};
// Reset preview when target lang changes
const handleTargetLangChange = (lang: string) => {
setTargetLang(lang);
setTranslatedPreview(null);
setTitlePreview({ translated: null, saving: false });
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) { setTranslatedPreview(null); setTitlePreview({ translated: null, saving: false }); } onOpenChange(v); }}>
<DialogContent className="w-[90vw] max-w-[90vw] h-[90vh] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-2 shrink-0">
<DialogTitle className="flex items-center gap-2">
<Languages className="h-5 w-5 text-blue-500" />
<T>Page Translations</T>
{widgetTypeId && (
<span className="text-sm font-normal text-muted-foreground">
{widgetTypeId}
{widgetInstanceId && <span className="font-mono text-xs ml-1">({widgetInstanceId.slice(0, 8)})</span>}
</span>
)}
</DialogTitle>
</DialogHeader>
{/* Page Meta Fields (Title, etc.) */}
{pageTitle && (
<div className="space-y-3 p-3 mx-6 bg-muted/50 rounded-lg border mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium"><T>Page Meta</T>:</span>
<Select value={srcLang} onValueChange={handleSrcLangChange}>
<SelectTrigger className="h-7 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs"></span>
<Select value={targetLang} onValueChange={handleTargetLangChange}>
<SelectTrigger className="h-7 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
{glossaries.length > 0 && (
<Select value={selectedGlossaryId} onValueChange={setSelectedGlossaryId}>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder={translate('Glossary')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"><T>No glossary</T></SelectItem>
{glossaries.map(g => (
<SelectItem key={g.glossary_id} value={g.glossary_id}>
{g.name} ({g.source_lang}{g.target_lang})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{renderMetaFieldRow(
translate('Title'),
'title',
pageTitle,
titlePreview,
setTitlePreview,
)}
</div>
)}
{/* Quick Translate Bar */}
{sourceContent && widgetInstanceId && (
<div className="space-y-2 p-3 mx-6 bg-muted/50 rounded-lg border mb-2">
{/* Header row */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium"><T>Quick Translate</T>:</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{sourceContent.slice(0, 120)}{sourceContent.length > 120 ? '…' : ''}
</span>
<div className="ml-auto flex items-center gap-2 flex-wrap justify-end">
<Select value={srcLang} onValueChange={handleSrcLangChange}>
<SelectTrigger className="h-7 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs"></span>
<Select value={targetLang} onValueChange={handleTargetLangChange}>
<SelectTrigger className="h-7 w-[80px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
{glossaries.length > 0 && (
<Select value={selectedGlossaryId} onValueChange={setSelectedGlossaryId}>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder={translate('Glossary')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"><T>No glossary</T></SelectItem>
{glossaries.map(g => (
<SelectItem key={g.glossary_id} value={g.glossary_id}>
{g.name} ({g.source_lang}{g.target_lang})
</SelectItem>
))}
</SelectContent>
</Select>
)}
{!translatedPreview && (
<Button
size="sm"
variant="outline"
onClick={handleQuickTranslate}
disabled={translating}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Translate</T>
</Button>
)}
</div>
</div>
{/* Preview area — shown after translation */}
{translatedPreview !== null && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase bg-primary/10 text-primary px-1.5 py-0.5 rounded">{targetLang}</span>
<span className="text-xs text-muted-foreground"><T>Preview</T> {translate('edit below then save')}</span>
</div>
<Textarea
value={translatedPreview}
onChange={e => setTranslatedPreview(e.target.value)}
rows={Math.min(8, Math.max(2, translatedPreview.split('\n').length))}
className="text-sm font-medium resize-y"
/>
<div className="flex items-center gap-2 justify-end">
<Button
size="sm"
variant="ghost"
onClick={handleDiscardPreview}
className="text-xs"
>
<X className="h-3.5 w-3.5 mr-1" />
<T>Discard</T>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleQuickTranslate}
disabled={translating}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Re-translate</T>
</Button>
<Button
size="sm"
onClick={handleSaveTranslation}
disabled={saving || !translatedPreview.trim()}
className="text-xs"
>
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
<T>Save Translation</T>
</Button>
</div>
</div>
)}
</div>
)}
{/* Full Panel */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6">
<WidgetTranslationPanel
filter={filter}
glossaries={glossaries}
showFilterBar={false}
autoLoad={open}
defaultSourceLang={srcLang}
defaultTargetLang={targetLang}
compact
refreshKey={panelRefreshKey}
/>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,340 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Loader2, Languages, X, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { T, translate, getCurrentLang } from '@/i18n';
import {
type Glossary,
TargetLanguageCodeSchema,
upsertWidgetTranslation,
fetchGlossaries,
} from './client-i18n';
import { WidgetTranslationPanel } from './WidgetTranslationPanel';
import { type WidgetTranslationFilter } from './useWidgetTranslation';
import { useWidgetTranslation } from './useWidgetTranslation';
import { toast } from 'sonner';
import { useAppConfig } from '@/hooks/useSystemInfo';
const TARGET_LANGS = [
...TargetLanguageCodeSchema.options[0].options,
...TargetLanguageCodeSchema.options[1].options,
].sort();
// ─── Types ──────────────────────────────────────────────
export interface TranslatableField {
/** Display label (e.g. "Name", "Description", "Title") */
label: string;
/** Storage path for this translation (e.g. "name", "fields.country.title") */
propPath: string;
/** Source text to translate */
sourceText: string;
/** Render as multiline textarea */
multiline?: boolean;
/** Visual group name — fields with the same group are rendered under a heading */
group?: string;
}
export interface TranslationsGridProps {
/** Entity type for storage (e.g. 'category', 'type', 'page') */
entityType: string;
/** Entity ID */
entityId: string;
/** Fields to translate */
fields: TranslatableField[];
/** Initial source language (falls back to appConfig) */
sourceLang?: string;
/** External glossaries; auto-loaded if not provided */
glossaries?: Glossary[];
/** Show the WidgetTranslationPanel below the field rows (default: true) */
showPanel?: boolean;
/** Extra class for the root container */
className?: string;
}
// ─── Internal ───────────────────────────────────────────
interface FieldTranslation {
translated: string | null;
saving: boolean;
}
const EMPTY: FieldTranslation = { translated: null, saving: false };
// ─── Component ──────────────────────────────────────────
export const TranslationsGrid: React.FC<TranslationsGridProps> = ({
entityType,
entityId,
fields,
sourceLang: initialSourceLangProp,
glossaries: externalGlossaries,
showPanel = true,
className,
}) => {
const appConfig = useAppConfig();
const srcLangDefault = appConfig?.i18n?.source_language || 'en';
const initialSourceLang = initialSourceLangProp || srcLangDefault;
const [srcLang, setSrcLang] = useState<string>(initialSourceLang);
const [targetLang, setTargetLang] = useState<string>(() => {
const cur = getCurrentLang();
return cur === initialSourceLang ? 'de' : cur;
});
const [translating, setTranslating] = useState(false);
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
const [glossaries, setGlossaries] = useState<Glossary[]>(externalGlossaries || []);
const [selectedGlossaryId, setSelectedGlossaryId] = useState<string>('none');
// Dynamic preview state keyed by propPath
const [previews, setPreviews] = useState<Record<string, FieldTranslation>>({});
const getPreview = (key: string): FieldTranslation => previews[key] || EMPTY;
const setPreview = (key: string, val: FieldTranslation) =>
setPreviews(prev => ({ ...prev, [key]: val }));
const resetAllPreviews = () => setPreviews({});
// Auto-load glossaries
useEffect(() => {
if (!externalGlossaries) {
fetchGlossaries().then(setGlossaries).catch(() => { });
}
}, [externalGlossaries]);
const wt = useWidgetTranslation({
defaultSourceLang: srcLang,
defaultTargetLang: targetLang,
});
const filter = useMemo<WidgetTranslationFilter>(() => ({
entityType,
entityId,
}), [entityType, entityId]);
// ─── Handlers ───────────────────────────────────────
const handleTranslateField = async (sourceText: string, propPath: string) => {
if (!sourceText) {
toast.error(translate('No content to translate'));
return;
}
setTranslating(true);
try {
const gid = selectedGlossaryId !== 'none' ? selectedGlossaryId : undefined;
const result = await wt.translateSingle(sourceText, srcLang, targetLang, gid);
if (result) setPreview(propPath, { translated: result, saving: false });
} finally {
setTranslating(false);
}
};
const handleSaveField = async (propPath: string, sourceText: string, translatedText: string) => {
setPreview(propPath, { translated: translatedText, saving: true });
try {
await upsertWidgetTranslation({
entity_type: entityType,
entity_id: entityId,
widget_id: null,
prop_path: propPath,
source_lang: srcLang,
target_lang: targetLang,
source_text: sourceText,
translated_text: translatedText,
status: 'machine',
});
toast.success(translate('Translation saved'));
setPreview(propPath, EMPTY);
setPanelRefreshKey(k => k + 1);
} catch (e: any) {
toast.error(e.message);
setPreview(propPath, { translated: translatedText, saving: false });
}
};
const handleTranslateAll = async () => {
setTranslating(true);
const gid = selectedGlossaryId !== 'none' ? selectedGlossaryId : undefined;
try {
for (const f of fields) {
if (!f.sourceText) continue;
const result = await wt.translateSingle(f.sourceText, srcLang, targetLang, gid);
if (result) setPreview(f.propPath, { translated: result, saving: false });
}
} finally {
setTranslating(false);
}
};
const handleSrcLangChange = (lang: string) => { setSrcLang(lang); resetAllPreviews(); };
const handleTargetLangChange = (lang: string) => { setTargetLang(lang); resetAllPreviews(); };
// ─── Render helpers ─────────────────────────────────
const renderFieldRow = (f: TranslatableField) => {
const preview = getPreview(f.propPath);
return (
<div key={f.propPath} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-muted-foreground w-24 shrink-0">{f.label}</span>
<span className="text-xs text-muted-foreground truncate flex-1">
{f.sourceText.slice(0, 120)}{f.sourceText.length > 120 ? '…' : ''}
</span>
{preview.translated === null && (
<Button
size="sm"
variant="outline"
onClick={() => handleTranslateField(f.sourceText, f.propPath)}
disabled={translating || !f.sourceText}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30 shrink-0"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Translate</T>
</Button>
)}
</div>
{preview.translated !== null && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase bg-primary/10 text-primary px-1.5 py-0.5 rounded">{targetLang}</span>
</div>
{f.multiline ? (
<Textarea
value={preview.translated}
onChange={e => setPreview(f.propPath, { ...preview, translated: e.target.value })}
rows={Math.min(4, Math.max(2, (preview.translated || '').split('\n').length))}
className="text-sm font-medium resize-y"
/>
) : (
<Input
value={preview.translated}
onChange={e => setPreview(f.propPath, { ...preview, translated: e.target.value })}
className="text-sm font-medium"
/>
)}
<div className="flex items-center gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setPreview(f.propPath, EMPTY)} className="text-xs">
<X className="h-3.5 w-3.5 mr-1" />
<T>Discard</T>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleTranslateField(f.sourceText, f.propPath)}
disabled={translating}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Re-translate</T>
</Button>
<Button
size="sm"
onClick={() => handleSaveField(f.propPath, f.sourceText, preview.translated!)}
disabled={preview.saving || !(preview.translated || '').trim()}
className="text-xs"
>
{preview.saving ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
<T>Save Translation</T>
</Button>
</div>
</div>
)}
</div>
);
};
// Group fields by their group prop
const ungrouped = fields.filter(f => !f.group);
const groups = [...new Set(fields.filter(f => f.group).map(f => f.group!))];
return (
<div className={className}>
{/* Language Selection & Translate All */}
<div className="space-y-3 p-3 bg-muted/50 rounded-lg border mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium"><T>Translate</T>:</span>
<Select value={srcLang} onValueChange={handleSrcLangChange}>
<SelectTrigger className="h-7 w-[80px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs"></span>
<Select value={targetLang} onValueChange={handleTargetLangChange}>
<SelectTrigger className="h-7 w-[80px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
{glossaries.length > 0 && (
<Select value={selectedGlossaryId} onValueChange={setSelectedGlossaryId}>
<SelectTrigger className="h-7 w-[140px] text-xs">
<SelectValue placeholder={translate('Glossary')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"><T>No glossary</T></SelectItem>
{glossaries.map(g => (
<SelectItem key={g.glossary_id} value={g.glossary_id}>
{g.name} ({g.source_lang}{g.target_lang})
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="ml-auto">
<Button
size="sm"
variant="outline"
onClick={handleTranslateAll}
disabled={translating}
className="text-xs text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30"
>
{translating ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
<T>Translate All</T>
</Button>
</div>
</div>
{/* Ungrouped fields */}
{ungrouped.map(renderFieldRow)}
</div>
{/* Grouped fields */}
{groups.map(groupName => (
<div key={groupName} className="space-y-3 p-3 bg-muted/50 rounded-lg border mb-2">
<div className="text-sm font-medium">{groupName}</div>
{fields
.filter(f => f.group === groupName)
.map(f => (
<div key={f.propPath} className="space-y-1.5 pl-2 border-l-2 border-primary/20">
{renderFieldRow(f)}
</div>
))}
</div>
))}
{/* WidgetTranslationPanel */}
{showPanel && (
<div className="flex-1 min-h-0 overflow-y-auto">
<WidgetTranslationPanel
filter={filter}
glossaries={glossaries}
showFilterBar={false}
autoLoad
defaultSourceLang={srcLang}
defaultTargetLang={targetLang}
compact
refreshKey={panelRefreshKey}
/>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,93 @@
import React from 'react';
import { Languages } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { T, translate } from '@/i18n';
import { type Glossary } from './client-i18n';
import { TranslationsGrid, type TranslatableField } from './TranslationsGrid';
export interface StructureFieldInfo {
fieldName: string;
title: string;
description?: string;
}
export interface TypeTranslationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
typeId: string;
typeName: string;
typeDescription?: string;
structureFields?: StructureFieldInfo[];
sourceLang?: string;
glossaries?: Glossary[];
}
export const TypeTranslationDialog: React.FC<TypeTranslationDialogProps> = ({
open,
onOpenChange,
typeId,
typeName,
typeDescription,
structureFields,
sourceLang,
glossaries,
}) => {
const fields: TranslatableField[] = [];
// Top-level type fields
if (typeName) {
fields.push({ label: translate('Name'), propPath: 'name', sourceText: typeName });
}
if (typeDescription) {
fields.push({ label: translate('Description'), propPath: 'description', sourceText: typeDescription, multiline: true });
}
// Structure fields — each gets its own group
if (structureFields) {
const groupLabel = translate('Structure Fields');
for (const sf of structureFields) {
if (sf.title) {
fields.push({
label: translate('Title'),
propPath: `fields.${sf.fieldName}.title`,
sourceText: sf.title,
group: groupLabel,
});
}
if (sf.description) {
fields.push({
label: translate('Description'),
propPath: `fields.${sf.fieldName}.description`,
sourceText: sf.description,
group: groupLabel,
});
}
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] max-w-[90vw] h-[90vh] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-2 shrink-0">
<DialogTitle className="flex items-center gap-2">
<Languages className="h-5 w-5 text-blue-500" />
<T>Type Translations</T>
<span className="text-sm font-normal text-muted-foreground">
{typeName}
</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 pb-6">
<TranslationsGrid
entityType="type"
entityId={typeId}
fields={fields}
sourceLang={sourceLang}
glossaries={glossaries}
/>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,496 @@
import React, { useState, useEffect, useMemo, useImperativeHandle, forwardRef } from 'react';
import { Loader2, Languages, Trash2, Save, Pencil, X, Search, Plus, FileUp, BookPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { T, translate } from '@/i18n';
import {
type WidgetTranslation,
type Glossary,
TargetLanguageCodeSchema,
} from './client-i18n';
import { useWidgetTranslation, type WidgetTranslationFilter } from './useWidgetTranslation';
import { useAppConfig } from '@/hooks/useSystemInfo';
const TARGET_LANGS = [
...TargetLanguageCodeSchema.options[0].options,
...TargetLanguageCodeSchema.options[1].options,
].sort();
const STATUSES = ['draft', 'machine', 'reviewed', 'published', 'outdated'] as const;
export interface WidgetTranslationPanelProps {
/** Pre-set filter for the panel (e.g. entity_type='page', entity_id=pageId) */
filter?: WidgetTranslationFilter;
/** External glossaries list for batch translate dropdown */
glossaries?: Glossary[];
/** Whether to show filter bar (entity type, entity ID, widget ID inputs) */
showFilterBar?: boolean;
/** Auto-load on mount */
autoLoad?: boolean;
/** Default source language */
defaultSourceLang?: string;
/** Default target language */
defaultTargetLang?: string;
/** Compact mode for embedding in dialogs */
compact?: boolean;
/** Callback when translations change */
onTranslationsChange?: (translations: WidgetTranslation[]) => void;
/** Increment to trigger a reload */
refreshKey?: number;
/** Callback when user clicks "Add to Glossary" on a row */
onAddToGlossary?: (item: WidgetTranslation) => void;
}
export interface WidgetTranslationPanelHandle {
replaceList: (items: import('./client-i18n').WidgetTranslation[]) => void;
}
export const WidgetTranslationPanel = forwardRef<WidgetTranslationPanelHandle, WidgetTranslationPanelProps>(({
filter: externalFilter,
glossaries = [],
showFilterBar = false,
autoLoad = true,
defaultSourceLang: defaultSourceLangProp,
defaultTargetLang: defaultTargetLangProp,
compact = false,
onTranslationsChange,
refreshKey,
onAddToGlossary,
}, ref) => {
const appConfig = useAppConfig();
const srcLangDefault = appConfig?.i18n?.source_language || 'en';
const defaultSourceLang = defaultSourceLangProp || srcLangDefault;
const defaultTargetLang = defaultTargetLangProp || 'de';
const wt = useWidgetTranslation({
filter: externalFilter,
autoLoad,
defaultSourceLang,
defaultTargetLang,
});
useImperativeHandle(ref, () => ({
replaceList: (items) => wt.setWtList(items),
}), [wt.setWtList]);
const [wtCreating, setWtCreating] = useState(false);
const [wtShowMissing, setWtShowMissing] = useState(false);
const [wtSearchText, setWtSearchText] = useState('');
const [wtBatchGlossaryId, setWtBatchGlossaryId] = useState<string>('none');
// Local filter state (only used when showFilterBar is true)
const [localEntityType, setLocalEntityType] = useState(externalFilter?.entityType || 'all');
const [localEntityId, setLocalEntityId] = useState(externalFilter?.entityId || '');
const [localWidgetId, setLocalWidgetId] = useState(externalFilter?.widgetId || '');
const [localTargetLang, setLocalTargetLang] = useState(externalFilter?.targetLang || 'all');
// Sync external filter changes
useEffect(() => {
if (externalFilter) {
wt.setFilter(externalFilter);
wt.loadWidgetTranslations(externalFilter);
}
}, [externalFilter?.entityType, externalFilter?.entityId, externalFilter?.widgetId, externalFilter?.targetLang]);
// Notify parent of changes
useEffect(() => {
onTranslationsChange?.(wt.wtList);
}, [wt.wtList]);
// Reload when refreshKey changes
useEffect(() => {
if (refreshKey !== undefined && refreshKey > 0) {
wt.loadWidgetTranslations(externalFilter);
}
}, [refreshKey]);
const handleSearch = () => {
const f: WidgetTranslationFilter = {};
if (localEntityType && localEntityType !== 'all') f.entityType = localEntityType;
if (localEntityId) f.entityId = localEntityId;
if (localWidgetId) f.widgetId = localWidgetId;
if (localTargetLang && localTargetLang !== 'all') f.targetLang = localTargetLang;
wt.setFilter(f);
wt.loadWidgetTranslations(f);
};
const filteredList = useMemo(() => {
let list = wtShowMissing ? wt.wtList.filter(item => !item.translated_text) : wt.wtList;
if (wtSearchText) {
const q = wtSearchText.toLowerCase();
list = list.filter(item =>
(item.source_text || '').toLowerCase().includes(q) ||
(item.translated_text || '').toLowerCase().includes(q)
);
}
return list;
}, [wt.wtList, wtShowMissing, wtSearchText]);
return (
<div className="space-y-4">
{/* Filter Bar */}
{showFilterBar && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<Select value={localEntityType} onValueChange={setLocalEntityType}>
<SelectTrigger><SelectValue placeholder={translate("Entity type")} /></SelectTrigger>
<SelectContent>
<SelectItem value="all"><T>All types</T></SelectItem>
{['page', 'post', 'system', 'collection'].map(t => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
<Input placeholder={translate("Entity ID")} value={localEntityId} onChange={e => setLocalEntityId(e.target.value)} />
<Input placeholder={translate("Widget ID")} value={localWidgetId} onChange={e => setLocalWidgetId(e.target.value)} />
<Select value={localTargetLang} onValueChange={setLocalTargetLang}>
<SelectTrigger><SelectValue placeholder={translate("Target lang")} /></SelectTrigger>
<SelectContent>
<SelectItem value="all"><T>All langs</T></SelectItem>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
<Button onClick={handleSearch} disabled={wt.wtLoading} className="flex-1">
{wt.wtLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"><T>Search</T></span>
</Button>
{localEntityType && localEntityId && (
<Button variant="destructive" size="icon" onClick={() => wt.handleWtDeleteByEntity()} title={translate("Delete all for this entity")}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
{/* Batch Actions Bar */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground">{wt.wtList.length} <T>entries</T></span>
<Button
size="sm"
variant={wtShowMissing ? 'default' : 'outline'}
onClick={() => setWtShowMissing(!wtShowMissing)}
>
{wtShowMissing ? translate('Showing missing only') : translate('Show missing')}
</Button>
{wt.wtList.some(item => !item.translated_text) && (
<>
{glossaries.length > 0 && (
<Select value={wtBatchGlossaryId} onValueChange={setWtBatchGlossaryId}>
<SelectTrigger className="w-[180px] h-8 text-xs">
<SelectValue placeholder={translate("Glossary")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"><T>No glossary</T></SelectItem>
{glossaries.map(g => (
<SelectItem key={g.glossary_id} value={g.glossary_id}>
{g.name} ({g.source_lang}{g.target_lang})
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button
size="sm"
variant="outline"
onClick={() => wt.handleWtBatchTranslate(wtBatchGlossaryId)}
disabled={wt.wtBatchTranslating}
className="text-blue-600 border-blue-200 hover:bg-blue-50 dark:border-blue-800 dark:hover:bg-blue-900/30"
>
{wt.wtBatchTranslating
? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> {wt.wtBatchProgress.done}/{wt.wtBatchProgress.total}</>
: <><Languages className="h-4 w-4 mr-1" /> <T>Translate</T> {wt.wtSelectedIds.size > 0 ? `(${wt.wtSelectedIds.size})` : translate('All Missing')}</>
}
</Button>
</>
)}
<Button
size="sm"
variant="outline"
onClick={wt.handleWtSaveToDB}
disabled={wt.wtSavingToDB || !wt.wtList.some(item => item.translated_text)}
className="text-orange-600 border-orange-200 hover:bg-orange-50 dark:border-orange-800 dark:hover:bg-orange-900/30"
title={translate("Save translated entries to database")}
>
{wt.wtSavingToDB ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
<T>Save to DB</T> {wt.wtSelectedIds.size > 0 ? `(${wt.wtSelectedIds.size})` : ''}
</Button>
<Button
size="sm"
variant="outline"
onClick={wt.handleUpdateI18n}
disabled={wt.wtUpdatingI18n || !wt.wtList.some(item => item.translated_text)}
className="text-green-600 border-green-200 hover:bg-green-50 dark:border-green-800 dark:hover:bg-green-900/30"
title={translate("Merge translations into i18n files")}
>
{wt.wtUpdatingI18n ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <FileUp className="h-4 w-4 mr-1" />}
<T>Update i18n</T> {wt.wtSelectedIds.size > 0 ? `(${wt.wtSelectedIds.size})` : ''}
</Button>
<Button size="sm" variant={wtCreating ? 'secondary' : 'default'} onClick={() => { wt.resetWtForm(); setWtCreating(!wtCreating); }}>
{wtCreating ? <X className="h-4 w-4 mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
{wtCreating ? translate('Cancel') : translate('New')}
</Button>
{(wtShowMissing || wt.wtBatchTranslating) && (
<span className="text-xs text-muted-foreground">
{wt.wtList.filter(item => !item.translated_text).length} of {wt.wtList.length} <T>missing</T>
</span>
)}
</div>
{/* Search within results */}
{wt.wtList.length > 0 && (
<Input
placeholder={translate("Search source or translation...")}
value={wtSearchText}
onChange={e => setWtSearchText(e.target.value)}
className="max-w-sm"
/>
)}
{/* Table */}
{wt.wtLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : wt.wtList.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<T>No widget translations found.</T>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="px-2 py-2 w-8">
<Checkbox
checked={wt.wtList.length > 0 && wt.wtSelectedIds.size === wt.wtList.length}
onCheckedChange={(checked) => {
if (checked) {
wt.setWtSelectedIds(new Set(wt.wtList.map(item => item.id)));
} else {
wt.setWtSelectedIds(new Set());
}
}}
/>
</th>
<th className="text-left px-2 py-2 font-medium text-muted-foreground w-[15%]"><T>Key</T></th>
<th className="text-left px-2 py-2 font-medium text-muted-foreground"><T>Source</T></th>
<th className="text-left px-2 py-2 font-medium text-muted-foreground"><T>Translation</T></th>
<th className="text-center px-2 py-2 font-medium text-muted-foreground w-24"><T>Status</T></th>
<th className="text-right px-2 py-2 font-medium text-muted-foreground w-20"></th>
</tr>
</thead>
<tbody className="divide-y">
{filteredList.map(item => {
const isEditing = wt.wtEditing === item.id;
return (
<tr
key={item.id}
className={`group transition-colors ${isEditing ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : 'hover:bg-muted/30'} ${wt.wtSelectedIds.has(item.id) ? 'bg-primary/5' : ''}`}
>
<td className="px-2 py-2.5 align-top">
<Checkbox
checked={wt.wtSelectedIds.has(item.id)}
onCheckedChange={(checked) => {
wt.setWtSelectedIds(prev => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-2 py-2.5 align-top">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${item.entity_type === 'system' ? 'bg-purple-500' :
item.entity_type === 'page' ? 'bg-blue-500' :
item.entity_type === 'category' ? 'bg-green-500' :
item.entity_type === 'post' ? 'bg-orange-500' : 'bg-gray-400'
}`} />
<span className="font-medium text-xs">{item.entity_type}</span>
</div>
{item.widget_id && (
<span className="text-xs text-muted-foreground font-mono truncate max-w-[140px]" title={item.widget_id}>
{item.widget_id}
</span>
)}
<span className="text-[11px] text-muted-foreground/70">.{item.prop_path}</span>
</div>
</td>
<td className="px-2 py-2.5 align-top">
<div className="flex items-start gap-1.5">
<span className="shrink-0 text-[10px] font-bold uppercase bg-muted px-1 py-0.5 rounded mt-0.5">{isEditing ? wt.wtForm.source_lang : item.source_lang}</span>
<span className="text-foreground/80 text-xs whitespace-pre-wrap max-h-24 overflow-y-auto">{(isEditing ? wt.wtForm.source_text : item.source_text) || <span className="italic text-muted-foreground"><T>empty</T></span>}</span>
</div>
</td>
<td className="px-2 py-2.5 align-top">
{isEditing ? (
<div className="flex flex-col gap-1">
<span className="shrink-0 text-[10px] font-bold uppercase bg-primary/10 text-primary px-1 py-0.5 rounded w-fit">{wt.wtForm.target_lang}</span>
<Textarea
value={wt.wtForm.translated_text || ''}
onChange={e => wt.setWtForm(f => ({ ...f, translated_text: e.target.value }))}
rows={3}
className="text-sm min-h-[3rem] resize-y font-medium"
autoFocus
/>
</div>
) : (
<div className="flex items-start gap-1.5">
<span className="shrink-0 text-[10px] font-bold uppercase bg-primary/10 text-primary px-1 py-0.5 rounded mt-0.5">{item.target_lang}</span>
<span className="font-medium text-xs whitespace-pre-wrap max-h-24 overflow-y-auto">{item.translated_text || <span className="italic text-muted-foreground"><T>empty</T></span>}</span>
</div>
)}
</td>
<td className="px-2 py-2.5 align-top text-center">
{isEditing ? (
<Select value={wt.wtForm.status || 'machine'} onValueChange={v => wt.setWtForm(f => ({ ...f, status: v as any }))}>
<SelectTrigger className="h-7 text-xs w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{STATUSES.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={`inline-flex items-center text-[11px] font-medium px-2 py-0.5 rounded-full ${item.status === 'published' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' :
item.status === 'reviewed' ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300' :
item.status === 'machine' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' :
item.status === 'outdated' ? 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' :
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
}`}>{item.status}</span>
)}
</td>
<td className="px-2 py-2.5 align-top text-right relative z-10">
{isEditing ? (
<div className="flex items-center justify-end gap-0.5">
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
onClick={wt.handleWtUpsert} disabled={wt.wtSaving} title={translate("Save")}
>
{wt.wtSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30"
onClick={() => wt.handleWtTranslate()} disabled={wt.wtTranslating || !wt.wtForm.source_text}
title={translate("Translate via DeepL")}
>
{wt.wtTranslating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Languages className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={wt.resetWtForm} title={translate("Cancel")}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => wt.handleWtEdit(item)} title={translate("Edit")}>
<Pencil className="h-3.5 w-3.5" />
</Button>
{onAddToGlossary && item.source_text && item.translated_text && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 dark:hover:bg-indigo-900/30" onClick={() => onAddToGlossary(item)} title={translate("Add to Glossary")}>
<BookPlus className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => wt.handleWtDelete(item.id)} title={translate("Delete")}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Create New Translation Form */}
{wtCreating && (
<div className="border-t pt-4 space-y-4">
<h3 className="text-lg font-medium"><T>Create Widget Translation</T></h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Select value={wt.wtForm.entity_type || 'page'} onValueChange={v => wt.setWtForm(f => ({ ...f, entity_type: v }))}>
<SelectTrigger><SelectValue placeholder={translate("Entity type")} /></SelectTrigger>
<SelectContent>
{['page', 'post', 'system', 'collection'].map(t => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
<Input placeholder={translate("Entity ID")} value={wt.wtForm.entity_id || ''} onChange={e => wt.setWtForm(f => ({ ...f, entity_id: e.target.value || null }))} />
<Input placeholder={translate("Widget ID")} value={wt.wtForm.widget_id || ''} onChange={e => wt.setWtForm(f => ({ ...f, widget_id: e.target.value || null }))} />
<Input placeholder={translate("Prop path")} value={wt.wtForm.prop_path || ''} onChange={e => wt.setWtForm(f => ({ ...f, prop_path: e.target.value }))} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Input placeholder={translate("Source lang")} value={wt.wtForm.source_lang} onChange={e => wt.setWtForm(f => ({ ...f, source_lang: e.target.value }))} />
<Select value={wt.wtForm.target_lang} onValueChange={v => wt.setWtForm(f => ({ ...f, target_lang: v }))}>
<SelectTrigger><SelectValue placeholder={translate("Target lang")} /></SelectTrigger>
<SelectContent>
{TARGET_LANGS.map(lang => (
<SelectItem key={lang} value={lang}>{lang}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={wt.wtForm.status || 'machine'} onValueChange={v => wt.setWtForm(f => ({ ...f, status: v as any }))}>
<SelectTrigger><SelectValue placeholder={translate("Status")} /></SelectTrigger>
<SelectContent>
{STATUSES.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<Textarea
placeholder={translate("Source text")}
value={wt.wtForm.source_text || ''}
onChange={e => wt.setWtForm(f => ({ ...f, source_text: e.target.value }))}
rows={3}
/>
<div className="relative">
<Textarea
placeholder={translate("Translated text")}
value={wt.wtForm.translated_text || ''}
onChange={e => wt.setWtForm(f => ({ ...f, translated_text: e.target.value }))}
rows={3}
/>
<Button
variant="ghost" size="sm"
className="absolute top-1 right-1 h-7 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30"
onClick={() => wt.handleWtTranslate()}
disabled={wt.wtTranslating || !wt.wtForm.source_text}
title={translate("Translate via DeepL")}
>
{wt.wtTranslating ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Languages className="h-3.5 w-3.5 mr-1" />}
DeepL
</Button>
</div>
</div>
<Button onClick={wt.handleWtUpsert} disabled={wt.wtSaving} className="w-full">
{wt.wtSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
<T>Create Translation</T>
</Button>
</div>
)}
</div>
);
});

View File

@ -107,3 +107,154 @@ export const deleteGlossary = async (id: string) => {
invalidateCache('i18n-glossaries');
return true;
};
export const fetchGlossaryTerms = async (glossaryId: string): Promise<Record<string, string>> => {
const res = await fetch(`/api/i18n/glossaries/${glossaryId}/terms`);
if (!res.ok) throw new Error(`Fetch glossary terms failed: ${res.statusText}`);
const data = await res.json();
return data.terms || {};
};
export const updateGlossaryTerms = async (glossaryId: string, entries: Record<string, string>): Promise<{ success: boolean; entry_count: number }> => {
const res = await fetch(`/api/i18n/glossaries/${glossaryId}/terms`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify({ entries }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(`Update glossary terms failed: ${err.error || res.statusText}`);
}
invalidateCache('i18n-glossaries');
return await res.json();
};
// --- Update i18n Language File ---
export const updateLangFile = async (lang: string, entries: Record<string, string>): Promise<{ success: boolean; total: number; added: number; updated: number }> => {
const res = await fetch('/api/i18n/update-lang-file', {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify({ lang, entries }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(`Update lang file failed: ${err.error || res.statusText}`);
}
return await res.json();
};
// --- Widget Translations ---
export interface WidgetTranslation {
id: string;
entity_type?: string | null;
entity_id?: string | null;
widget_id?: string | null;
prop_path?: string | null;
source_lang: string;
target_lang: string;
source_text?: string | null;
translated_text?: string | null;
source_version?: number | null;
status?: 'draft' | 'machine' | 'reviewed' | 'published' | 'outdated';
meta?: any;
created_at?: string;
updated_at?: string;
}
export type WidgetTranslationInput = Omit<WidgetTranslation, 'id' | 'created_at' | 'updated_at'>;
const authHeaders = async (): Promise<HeadersInit> => {
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
};
export const fetchWidgetTranslations = async (criteria: {
entityType?: string;
entityId?: string;
widgetId?: string;
targetLang?: string;
sourceLang?: string;
status?: string;
} = {}): Promise<WidgetTranslation[]> => {
const params = new URLSearchParams();
Object.entries(criteria).forEach(([k, v]) => { if (v) params.set(k, v); });
const res = await fetch(`/api/i18n/widget-translations?${params.toString()}`);
if (!res.ok) throw new Error(`Fetch widget translations failed: ${res.statusText}`);
return await res.json();
};
export const upsertWidgetTranslation = async (data: WidgetTranslationInput): Promise<WidgetTranslation> => {
const res = await fetch('/api/i18n/widget-translations', {
method: 'POST',
headers: await authHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(`Upsert widget translation failed: ${err.error || res.statusText}`);
}
return await res.json();
};
export const upsertWidgetTranslationsBatch = async (data: WidgetTranslationInput[]): Promise<WidgetTranslation[]> => {
const res = await fetch('/api/i18n/widget-translations/batch', {
method: 'POST',
headers: await authHeaders(),
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(`Batch upsert failed: ${err.error || res.statusText}`);
}
return await res.json();
};
export const deleteWidgetTranslation = async (id: string): Promise<boolean> => {
const headers = await authHeaders();
const res = await fetch(`/api/i18n/widget-translations/${id}`, {
method: 'DELETE',
headers,
});
if (!res.ok) throw new Error(`Delete widget translation failed: ${res.statusText}`);
return true;
};
export const deleteWidgetTranslationsByEntity = async (entityType: string, entityId: string, targetLang?: string): Promise<boolean> => {
const headers = await authHeaders();
const params = targetLang ? `?targetLang=${targetLang}` : '';
const res = await fetch(`/api/i18n/widget-translations/entity/${entityType}/${entityId}${params}`, {
method: 'DELETE',
headers,
});
if (!res.ok) throw new Error(`Delete widget translations by entity failed: ${res.statusText}`);
return true;
};
// --- Translation Gaps ---
export const fetchTranslationGaps = async (
entityType: string,
targetLang: string,
sourceLang?: string,
mode?: 'missing' | 'outdated' | 'all',
): Promise<WidgetTranslation[]> => {
const params = new URLSearchParams({ entityType, targetLang });
if (sourceLang) params.set('sourceLang', sourceLang);
if (mode) params.set('mode', mode);
const res = await fetch(`/api/i18n/translation-gaps?${params.toString()}`);
if (!res.ok) throw new Error(`Fetch translation gaps failed: ${res.statusText}`);
return await res.json();
};

View File

@ -0,0 +1,379 @@
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import {
translateText,
fetchWidgetTranslations,
upsertWidgetTranslation,
upsertWidgetTranslationsBatch,
deleteWidgetTranslation,
deleteWidgetTranslationsByEntity,
updateLangFile,
type WidgetTranslation,
type WidgetTranslationInput,
} from './client-i18n';
import { translate } from '@/i18n';
export interface WidgetTranslationFilter {
entityType?: string;
entityId?: string;
widgetId?: string;
targetLang?: string;
sourceLang?: string;
}
export interface UseWidgetTranslationOptions {
/** Initial filter criteria */
filter?: WidgetTranslationFilter;
/** Auto-load on mount */
autoLoad?: boolean;
/** Default source language */
defaultSourceLang?: string;
/** Default target language */
defaultTargetLang?: string;
}
export function useWidgetTranslation(options: UseWidgetTranslationOptions = {}) {
const {
filter: initialFilter,
autoLoad = false,
defaultSourceLang = 'de',
defaultTargetLang = 'en',
} = options;
// List state
const [wtList, setWtList] = useState<WidgetTranslation[]>([]);
const [wtLoading, setWtLoading] = useState(false);
// Filter state
const [filter, setFilter] = useState<WidgetTranslationFilter>(initialFilter || {});
// Form state
const [wtForm, setWtForm] = useState<WidgetTranslationInput>({
entity_type: initialFilter?.entityType || 'page',
entity_id: initialFilter?.entityId || null,
widget_id: initialFilter?.widgetId || null,
prop_path: 'content',
source_lang: defaultSourceLang,
target_lang: defaultTargetLang,
source_text: '',
translated_text: '',
status: 'machine',
});
const [wtEditing, setWtEditing] = useState<string | null>(null);
// Operation states
const [wtSaving, setWtSaving] = useState(false);
const [wtTranslating, setWtTranslating] = useState(false);
const [wtBatchTranslating, setWtBatchTranslating] = useState(false);
const [wtBatchProgress, setWtBatchProgress] = useState({ done: 0, total: 0 });
const [wtSavingToDB, setWtSavingToDB] = useState(false);
const [wtUpdatingI18n, setWtUpdatingI18n] = useState(false);
// Selection state
const [wtSelectedIds, setWtSelectedIds] = useState<Set<string>>(new Set());
const loadWidgetTranslations = useCallback(async (overrideFilter?: WidgetTranslationFilter) => {
setWtLoading(true);
try {
const criteria: any = {};
const f = overrideFilter || filter;
if (f.entityType) criteria.entityType = f.entityType;
if (f.entityId) criteria.entityId = f.entityId;
if (f.widgetId) criteria.widgetId = f.widgetId;
if (f.targetLang) criteria.targetLang = f.targetLang;
if (f.sourceLang) criteria.sourceLang = f.sourceLang;
const data = await fetchWidgetTranslations(criteria);
setWtList(data);
} catch (e: any) {
toast.error(translate(`Failed to load widget translations: ${e.message}`));
} finally {
setWtLoading(false);
}
}, [filter]);
useEffect(() => {
if (autoLoad) loadWidgetTranslations();
}, [autoLoad]);
const handleWtUpsert = useCallback(async () => {
if (!wtForm.source_lang || !wtForm.target_lang) {
toast.error(translate('source_lang and target_lang are required'));
return;
}
setWtSaving(true);
try {
await upsertWidgetTranslation(wtForm);
toast.success(translate(wtEditing ? 'Widget translation updated' : 'Widget translation created'));
resetWtForm();
loadWidgetTranslations();
} catch (e: any) {
toast.error(e.message);
} finally {
setWtSaving(false);
}
}, [wtForm, wtEditing, loadWidgetTranslations]);
const handleWtTranslate = useCallback(async (glossaryId?: string) => {
if (!wtForm.source_text) {
toast.error(translate('Enter source text first'));
return;
}
if (!wtForm.source_lang || !wtForm.target_lang) {
toast.error(translate('Source and target language required'));
return;
}
setWtTranslating(true);
try {
const res = await translateText(wtForm.source_text, wtForm.source_lang, wtForm.target_lang, glossaryId);
setWtForm(f => ({ ...f, translated_text: res.translation }));
toast.success(translate('Translated via DeepL'));
} catch (e: any) {
toast.error(`${translate('Translation failed')}: ${e.message}`);
} finally {
setWtTranslating(false);
}
}, [wtForm]);
const handleWtEdit = useCallback((wt: WidgetTranslation) => {
setWtEditing(wt.id);
setWtForm({
entity_type: wt.entity_type || 'page',
entity_id: wt.entity_id || null,
widget_id: wt.widget_id || null,
prop_path: wt.prop_path || 'content',
source_lang: wt.source_lang,
target_lang: wt.target_lang,
source_text: wt.source_text || '',
translated_text: wt.translated_text || '',
status: wt.status || 'machine',
source_version: wt.source_version,
meta: wt.meta,
});
}, []);
const handleWtDelete = useCallback(async (id: string) => {
try {
await deleteWidgetTranslation(id);
toast.success(translate('Widget translation deleted'));
setWtList(prev => prev.filter(wt => wt.id !== id));
} catch (e: any) {
toast.error(e.message);
}
}, []);
const handleWtDeleteByEntity = useCallback(async (entityType?: string, entityId?: string, targetLang?: string) => {
const et = entityType || filter.entityType;
const eid = entityId || filter.entityId;
if (!et || !eid) {
toast.error(translate('Entity type and entity ID required for bulk delete'));
return;
}
try {
await deleteWidgetTranslationsByEntity(et, eid, targetLang || undefined);
toast.success(translate('Widget translations deleted for entity'));
loadWidgetTranslations();
} catch (e: any) {
toast.error(e.message);
}
}, [filter, loadWidgetTranslations]);
const resetWtForm = useCallback(() => {
setWtEditing(null);
setWtForm({
entity_type: filter.entityType || 'page',
entity_id: filter.entityId || null,
widget_id: filter.widgetId || null,
prop_path: 'content',
source_lang: defaultSourceLang,
target_lang: defaultTargetLang,
source_text: '',
translated_text: '',
status: 'machine',
});
}, [filter, defaultSourceLang, defaultTargetLang]);
const handleWtBatchTranslate = useCallback(async (glossaryId?: string) => {
const candidates = wtSelectedIds.size > 0
? wtList.filter(wt => wtSelectedIds.has(wt.id) && wt.source_text && !wt.translated_text)
: wtList.filter(wt => wt.source_text && !wt.translated_text);
if (candidates.length === 0) {
toast.info(translate('No untranslated entries') + (wtSelectedIds.size > 0 ? ` ${translate('in selection')}` : ''));
return;
}
setWtBatchTranslating(true);
setWtBatchProgress({ done: 0, total: candidates.length });
let translated = 0;
let failed = 0;
for (const wt of candidates) {
try {
const gid = glossaryId && glossaryId !== 'none' ? glossaryId : undefined;
const res = await translateText(wt.source_text!, wt.source_lang, wt.target_lang, gid);
setWtList(prev => prev.map(item =>
item.id === wt.id
? { ...item, translated_text: res.translation, status: 'machine' as const }
: item
));
translated++;
} catch {
failed++;
}
setWtBatchProgress({ done: translated + failed, total: candidates.length });
}
setWtBatchTranslating(false);
toast.success(`${translate('Batch done')}: ${translated} ${translate('translated')}, ${failed} ${translate('failed')}`);
}, [wtList, wtSelectedIds]);
const handleWtSaveToDB = useCallback(async () => {
const source = wtSelectedIds.size > 0
? wtList.filter(wt => wtSelectedIds.has(wt.id) && wt.translated_text)
: wtList.filter(wt => wt.translated_text);
if (source.length === 0) {
toast.info(translate('No translated entries to save'));
return;
}
setWtSavingToDB(true);
try {
const batch: WidgetTranslationInput[] = source.map(wt => ({
entity_type: wt.entity_type || 'system',
entity_id: wt.entity_id || null,
widget_id: wt.widget_id || null,
prop_path: wt.prop_path,
source_lang: wt.source_lang,
target_lang: wt.target_lang,
source_text: wt.source_text || '',
translated_text: wt.translated_text || '',
status: wt.status || 'machine',
}));
await upsertWidgetTranslationsBatch(batch);
toast.success(`${translate('Saved')} ${batch.length} ${translate('translations to database')}`);
loadWidgetTranslations();
} catch (e: any) {
toast.error(`${translate('Save to DB failed')}: ${e.message}`);
} finally {
setWtSavingToDB(false);
}
}, [wtList, wtSelectedIds, loadWidgetTranslations]);
const handleUpdateI18n = useCallback(async () => {
const source = wtSelectedIds.size > 0
? wtList.filter(wt => wtSelectedIds.has(wt.id))
: wtList;
const byLang: Record<string, Record<string, string>> = {};
for (const wt of source) {
if (!wt.source_text || !wt.translated_text) continue;
const lang = wt.target_lang;
if (!byLang[lang]) byLang[lang] = {};
byLang[lang][wt.source_text] = wt.translated_text;
}
const langs = Object.keys(byLang);
if (langs.length === 0) {
toast.info(translate('No translated entries to push'));
return;
}
setWtUpdatingI18n(true);
try {
const results: string[] = [];
for (const lang of langs) {
const res = await updateLangFile(lang, byLang[lang]);
results.push(`${lang}: +${res.added} new, ~${res.updated} updated (${res.total} total)`);
}
toast.success(`${translate('Updated i18n files')}:\n${results.join('\n')}`);
} catch (e: any) {
toast.error(`${translate('Update i18n failed')}: ${e.message}`);
} finally {
setWtUpdatingI18n(false);
}
}, [wtList, wtSelectedIds]);
/** Translate a single text and return the result (no form interaction) */
const translateSingle = useCallback(async (
sourceText: string,
sourceLang: string,
targetLang: string,
glossaryId?: string,
): Promise<string | null> => {
try {
const res = await translateText(sourceText, sourceLang, targetLang, glossaryId);
return res.translation;
} catch (e: any) {
toast.error(`${translate('Translation failed')}: ${e.message}`);
return null;
}
}, []);
/** Translate source text and immediately save as a widget translation */
const translateAndSave = useCallback(async (
sourceText: string,
sourceLang: string,
targetLang: string,
entityType: string,
entityId: string,
widgetId: string,
propPath: string = 'content',
glossaryId?: string,
): Promise<WidgetTranslation | null> => {
try {
const res = await translateText(sourceText, sourceLang, targetLang, glossaryId);
const saved = await upsertWidgetTranslation({
entity_type: entityType,
entity_id: entityId,
widget_id: widgetId,
prop_path: propPath,
source_lang: sourceLang,
target_lang: targetLang,
source_text: sourceText,
translated_text: res.translation,
status: 'machine',
});
toast.success(translate('Translation saved'));
return saved;
} catch (e: any) {
toast.error(`${translate('Translation failed')}: ${e.message}`);
return null;
}
}, []);
return {
// State
wtList,
setWtList,
wtLoading,
wtForm,
setWtForm,
wtEditing,
wtSaving,
wtTranslating,
wtBatchTranslating,
wtBatchProgress,
wtSavingToDB,
wtUpdatingI18n,
wtSelectedIds,
setWtSelectedIds,
filter,
setFilter,
// Actions
loadWidgetTranslations,
handleWtUpsert,
handleWtTranslate,
handleWtEdit,
handleWtDelete,
handleWtDeleteByEntity,
resetWtForm,
handleWtBatchTranslate,
handleWtSaveToDB,
handleUpdateI18n,
translateSingle,
translateAndSave,
};
}

View File

@ -144,30 +144,41 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
/>
))}
{/* Add Widget Buttons - one per column for non-empty containers (in edit mode) */}
{isEditMode && container.widgets.length > 0 && container.children.length === 0 && (
{/* Add Widget Buttons - always visible in edit mode at bottom of every container */}
{isEditMode && (
<>
{Array.from({ length: container.columns }, (_, colIndex) => (
{container.columns > 1 ? (
Array.from({ length: container.columns }, (_, colIndex) => (
<div
key={`add-widget-${colIndex}`}
className="flex items-center justify-center min-h-[40px] rounded-lg hover:border-blue-400 dark:hover:border-blue-500 transition-colors cursor-pointer group"
onClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
}}
title={`Add widget to column ${colIndex + 1}`}
>
<div className="text-center text-slate-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400 transition-colors">
<Plus className="h-4 w-4 mx-auto" />
<p className="text-[10px]">Col {colIndex + 1}</p>
</div>
</div>
))
) : (
<div
key={`add-widget-${colIndex}`}
className="flex items-center justify-center min-h-[60px] rounded-lg hover:border-blue-400 dark:hover:border-blue-500 transition-colors cursor-pointer group"
className="col-span-full flex items-center justify-center min-h-[36px] rounded-lg hover:bg-blue-50/30 dark:hover:bg-blue-900/20 transition-colors cursor-pointer group"
onClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
onAddWidget?.(container.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
onAddWidget?.(container.id, colIndex);
}}
title={`Click to add widget to column ${colIndex + 1}`}
title="Add widget"
>
<div className="text-center text-slate-500 dark:text-slate-400 group-hover:text-blue-500 dark:group-hover:text-blue-400 transition-colors">
<Plus className="h-5 w-5 mx-auto mb-1" />
<p className="text-xs">Add Widget</p>
{container.columns > 1 && <p className="text-xs opacity-60">Col {colIndex + 1}</p>}
<div className="flex items-center gap-1 text-slate-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400 transition-colors">
<Plus className="h-4 w-4" />
<span className="text-xs">Add Widget</span>
</div>
</div>
))}
)}
</>
)}
@ -207,27 +218,9 @@ const LayoutContainerComponent: React.FC<LayoutContainerProps> = ({
{/* Empty State - only show when not showing column indicators */}
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && (
<div
className={cn(
"col-span-full flex items-center justify-center min-h-[80px] text-slate-500 dark:text-slate-400",
isEditMode && "cursor-pointer hover:bg-slate-100/20 dark:hover:bg-slate-800/20 transition-colors"
)}
onDoubleClick={isEditMode ? (e) => {
e.stopPropagation();
onSelect?.(container.id, pageId);
setTimeout(() => onAddWidget?.(container.id), 100); // Small delay to ensure selection happens first, no column = append
} : undefined}
title={isEditMode ? "Double-click to add widget" : undefined}
>
{isEditMode ? (
<div className="text-center">
<Plus className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Double-click to add widgets</p>
</div>
) : (
<p className="text-sm"></p>
)}
{container.widgets.length === 0 && container.children.length === 0 && !(isEditMode && isSelected) && !isEditMode && (
<div className="col-span-full flex items-center justify-center min-h-[80px] text-slate-500 dark:text-slate-400">
<p className="text-sm"></p>
</div>
)}
</>

View File

@ -1,5 +1,6 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { SupabaseClient } from "@supabase/supabase-js";
import { fetchWithDeduplication } from "@/lib/db";
export const createLayout = async (layoutData: any, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
@ -25,57 +26,63 @@ export const createLayout = async (layoutData: any, client?: SupabaseClient) =>
};
export const getLayout = async (layoutId: string, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const key = `layout-${layoutId}`;
return fetchWithDeduplication(key, async () => {
const supabase = client || defaultSupabase;
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts/${layoutId}`, {
method: 'GET',
headers
const res = await fetch(`/api/layouts/${layoutId}`, {
method: 'GET',
headers
});
if (!res.ok) {
throw new Error(`Failed to fetch layout: ${res.statusText}`);
}
// Wrap in object to match Supabase response format { data, error }
const data = await res.json();
return { data, error: null };
});
if (!res.ok) {
throw new Error(`Failed to fetch layout: ${res.statusText}`);
}
// Wrap in object to match Supabase response format { data, error }
const data = await res.json();
return { data, error: null };
};
export const getLayouts = async (filters?: { type?: string, visibility?: string, limit?: number, offset?: number }, client?: SupabaseClient) => {
const supabase = client || defaultSupabase;
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const params = new URLSearchParams();
if (filters?.type) params.append('type', filters.type);
if (filters?.visibility) params.append('visibility', filters.visibility);
if (filters?.limit) params.append('limit', filters.limit.toString());
if (filters?.offset) params.append('offset', filters.offset.toString());
const res = await fetch(`/api/layouts?${params.toString()}`, {
method: 'GET',
headers
const key = `layouts-${params.toString()}`;
return fetchWithDeduplication(key, async () => {
const supabase = client || defaultSupabase;
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/layouts?${params.toString()}`, {
method: 'GET',
headers
});
if (!res.ok) {
throw new Error(`Failed to fetch layouts: ${res.statusText}`);
}
// Wrap in object to match Supabase response format { data, error }
const data = await res.json();
return { data, error: null };
});
if (!res.ok) {
throw new Error(`Failed to fetch layouts: ${res.statusText}`);
}
// Wrap in object to match Supabase response format { data, error }
const data = await res.json();
return { data, error: null };
};
export const updateLayoutMeta = async (layoutId: string, metaUpdates: any) => {
// Fetch current layout to merge meta

View File

@ -864,3 +864,75 @@ export class PasteWidgetsCommand implements Command {
context.updateLayout(this.pageId, newLayout);
}
}
// --- Paste Containers Command ---
export class PasteContainersCommand implements Command {
id: string;
type = 'PASTE_CONTAINERS';
timestamp: number;
private pageId: string;
private containers: LayoutContainer[];
private insertIndex: number; // -1 means append
private pastedIds: string[] = [];
constructor(pageId: string, containers: LayoutContainer[], insertIndex: number = -1) {
this.id = crypto.randomUUID();
this.timestamp = Date.now();
this.pageId = pageId;
this.insertIndex = insertIndex;
// Deep clone containers with new IDs
const cloneContainer = (c: LayoutContainer): LayoutContainer => {
const suffix = crypto.randomUUID().slice(0, 6);
return {
...JSON.parse(JSON.stringify(c)),
id: `${c.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`,
widgets: c.widgets.map(w => ({
...JSON.parse(JSON.stringify(w)),
id: `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${crypto.randomUUID().slice(0, 6)}`
})),
children: c.children ? c.children.map(cloneContainer) : []
};
};
this.containers = containers.map(cloneContainer);
this.pastedIds = this.containers.map(c => c.id);
}
async execute(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
if (this.insertIndex >= 0 && this.insertIndex < newLayout.containers.length) {
// Insert after the given index
newLayout.containers.splice(this.insertIndex + 1, 0, ...this.containers.map(c => JSON.parse(JSON.stringify(c))));
} else {
// Append at end
for (const container of this.containers) {
newLayout.containers.push(JSON.parse(JSON.stringify(container)));
}
}
// Reorder
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
async undo(context: CommandContext): Promise<void> {
const layout = context.layouts.get(this.pageId);
if (!layout) throw new Error(`Layout not found: ${this.pageId}`);
const newLayout = JSON.parse(JSON.stringify(layout)) as PageLayout;
const idsToRemove = new Set(this.pastedIds);
newLayout.containers = newLayout.containers.filter(c => !idsToRemove.has(c.id));
newLayout.containers.forEach((c, i) => c.order = i);
newLayout.updatedAt = Date.now();
context.updateLayout(this.pageId, newLayout);
}
}

View File

@ -27,6 +27,7 @@ import { CategoryManager } from "@/components/widgets/CategoryManager";
import { getLayouts } from "@/modules/layout/client-layouts";
import { useQuery } from "@tanstack/react-query";
import { fetchCategories, Category } from "@/modules/categories/client-categories";
import { useAppConfig } from '@/hooks/useSystemInfo';
const NewPage = () => {
const navigate = useNavigate();
@ -34,6 +35,8 @@ const NewPage = () => {
const { orgSlug } = useParams<{ orgSlug?: string }>();
const [searchParams] = useSearchParams();
const parentPageId = searchParams.get('parent');
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language;
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
@ -69,7 +72,7 @@ const NewPage = () => {
// Fetch categories for display
const { data: allCategories = [] } = useQuery({
queryKey: ['categories'],
queryFn: () => fetchCategories({ includeChildren: true })
queryFn: () => fetchCategories({ includeChildren: true, sourceLang: srcLang })
});
// Flatten categories for name lookup

View File

@ -154,10 +154,10 @@ export const PageActions = ({
try {
await navigator.clipboard.writeText(url);
toast.success("Link copied to clipboard");
toast.success(translate("Link copied to clipboard"));
} catch (e) {
console.error('Clipboard failed', e);
toast.error("Failed to copy link");
toast.error(translate("Failed to copy link"));
}
};
@ -226,10 +226,10 @@ export const PageActions = ({
// Open in new tab to trigger download
window.open(exportUrl, '_blank');
toast.success("Markdown export opened");
toast.success(translate("Markdown export opened"));
} catch (e) {
console.error("Markdown export failed", e);
toast.error("Failed to export Markdown");
toast.error(translate("Failed to export Markdown"));
}
};
@ -253,9 +253,9 @@ export const PageActions = ({
try {
await navigator.clipboard.writeText(iframeCode);
toast.success("Embed code copied to clipboard");
toast.success(translate("Embed code copied to clipboard"));
} catch (e) {
toast.error("Failed to copy embed code");
toast.error(translate("Failed to copy embed code"));
}
};
@ -315,10 +315,10 @@ draft: ${!page.visible}
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Astro export downloaded");
toast.success(translate("Astro export downloaded"));
} catch (e) {
console.error("Astro export failed", e);
toast.error("Failed to export Astro");
toast.error(translate("Failed to export Astro"));
}
};
@ -331,10 +331,10 @@ draft: ${!page.visible}
// Open in new tab to trigger download
window.open(exportUrl, '_blank');
toast.success("PDF export opened");
toast.success(translate("PDF export opened"));
} catch (e) {
console.error(e);
toast.error("Failed to export PDF");
toast.error(translate("Failed to export PDF"));
}
};
@ -343,10 +343,10 @@ draft: ${!page.visible}
const pageJson = JSON.stringify(page, null, 2);
console.log('Page JSON:', pageJson);
await navigator.clipboard.writeText(pageJson);
toast.success("Page JSON dumped to console and clipboard");
toast.success(translate("Page JSON dumped to console and clipboard"));
} catch (e) {
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
toast.error(translate("Failed to dump JSON"));
}
};
@ -394,19 +394,19 @@ draft: ${!page.visible}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Export & Share</DropdownMenuLabel>
<DropdownMenuLabel><T>Export & Share</T></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="h-4 w-4 mr-2" />
<span>Copy Link</span>
<span><T>Copy Link</T></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileText className="h-4 w-4 mr-2" />
<span>Export Markdown</span>
<span><T>Export Markdown</T></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf}>
<FileText className="h-4 w-4 mr-2" />
<span>Export PDF</span>
<span><T>Export PDF</T></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportAstro}>
<FileText className="mr-2 h-4 w-4" />

View File

@ -116,7 +116,7 @@ const PageCard: React.FC<PageCardProps> = ({
src={displayImage}
alt={title}
className="w-full h-full"
imgClassName="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
imgClassName="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
sizes="100vw"
data={responsive}
apiUrl={apiUrl}

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, Suspense, lazy } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { getCurrentLang } from "@/i18n";
import { Button } from "@/components/ui/button";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
@ -24,6 +25,7 @@ import { UserPageTopBar } from "./UserPageTopBar";
import { UserPageDetails } from "./editor/UserPageDetails";
import { SEO } from "@/components/SEO";
import { useAppConfig } from '@/hooks/useSystemInfo';
const UserPageEdit = lazy(() => import("./editor/UserPageEdit"));
@ -40,8 +42,10 @@ interface UserPageProps {
const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false, initialPage }: UserPageProps) => {
const { userId: paramUserId, username: paramUsername, slug: paramSlug, orgSlug } = useParams<{ userId: string; username: string; slug: string; orgSlug?: string }>();
const navigate = useNavigate();
const { user: currentUser } = useAuth();
const { user: currentUser, roles } = useAuth();
const { getLoadedPageLayout, loadPageLayout, hydratePageLayout } = useLayout();
const appConfig = useAppConfig();
const srcLang = appConfig?.i18n?.source_language || 'en';
const [resolvedUserId, setResolvedUserId] = useState<string | null>(null);
@ -49,6 +53,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
const userId = propUserId || paramUserId || resolvedUserId;
const [page, setPage] = useState<Page | null>(initialPage || null);
const [originalPage, setOriginalPage] = useState<Page | null>(initialPage || null);
const [childPages, setChildPages] = useState<{ id: string; title: string; slug: string }[]>([]);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
@ -65,7 +70,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
}
}, [headings.length]);
const isOwner = currentUser?.id === userId;
const isOwner = currentUser?.id === userId || roles.includes('admin');
useEffect(() => {
if (initialPage) {
@ -89,10 +94,29 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
if (data) {
setPage(data.page);
setUserProfile(data.userProfile as any); // Cast to match local interface if needed
setUserProfile(data.userProfile as any);
setChildPages(data.childPages || []);
// If we resolved via username, ensure we have the userId for isOwner check
// If a non-English lang is active, the response has translated content.
// Fetch original (no lang) for the editor to avoid saving translations as source.
const lang = getCurrentLang();
if (lang && lang !== srcLang) {
const { supabase: defaultSupabase } = await import('@/integrations/supabase/client');
const { data: sessionData } = await defaultSupabase.auth.getSession();
const token = sessionData.session?.access_token;
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/user-page/${id}/${slugStr}`, { headers });
if (res.ok) {
const orig = await res.json();
setOriginalPage(orig.page);
} else {
setOriginalPage(data.page); // fallback
}
} else {
setOriginalPage(data.page);
}
if (!resolvedUserId && data.page.owner) {
setResolvedUserId(data.page.owner);
}
@ -170,6 +194,30 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
}
}, [headings]); // Run when headings are populated
// Re-hydrate layout context when switching between edit and view modes.
// Edit mode must use original (English) content so saves don't overwrite source with translations.
// View mode uses (potentially translated) page content.
useEffect(() => {
if (!page) return;
const lang = getCurrentLang();
if (!lang || lang === srcLang) return; // No translation active, nothing to swap
const pageLayoutId = `page-${page.id}`;
const source = isEditMode ? originalPage : page;
if (!source?.content || typeof source.content === 'string') return;
const content = source.content as any;
let resolved = null;
if (content.id && content.containers) {
resolved = content;
} else if (content.pages && content.pages[pageLayoutId]) {
resolved = content.pages[pageLayoutId];
}
if (resolved) {
hydratePageLayout(pageLayoutId, resolved);
}
}, [isEditMode]);
// Actions now handled by PageActions component
@ -186,6 +234,11 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
}
} else {
setPage(updatedPage);
// Keep originalPage in sync so the editor (which receives originalPage || page) sees changes.
// Preserve original English content — only update metadata (title, categories, meta, etc.)
if (isEditMode) {
setOriginalPage(prev => prev ? { ...prev, ...updatedPage, content: prev.content } : updatedPage);
}
}
};
@ -214,7 +267,7 @@ const UserPageContent = ({ userId: propUserId, slug: propSlug, embedded = false,
return (
<Suspense fallback={<div className="h-screen w-full flex items-center justify-center"><T>Loading Editor...</T></div>}>
<UserPageEdit
page={page}
page={originalPage || page}
userProfile={userProfile}
isOwner={isOwner}
userId={userId || ''}

View File

@ -1,5 +1,6 @@
import { supabase as defaultSupabase } from "@/integrations/supabase/client";
import { fetchWithDeduplication } from "@/lib/db";
import { getCurrentLang } from '@/i18n';
export const fetchUserPage = async (userId: string, slug: string) => {
const key = `user-page-${userId}-${slug}`;
@ -9,7 +10,9 @@ export const fetchUserPage = async (userId: string, slug: string) => {
const headers: HeadersInit = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/user-page/${userId}/${slug}`, { headers });
const lang = getCurrentLang();
const langParam = lang && lang !== 'en' ? `?lang=${lang}` : '';
const res = await fetch(`/api/user-page/${userId}/${slug}${langParam}`, { headers });
if (!res.ok) {
if (res.status === 404) return null;

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { T } from '@/i18n';
import { T, translate } from '@/i18n';
import { AIPageGenerator } from '@/modules/pages/AIPageGenerator';
import { runTools } from '@/lib/openai';
import { toast } from 'sonner';
@ -102,16 +102,15 @@ export const AILayoutWizard: React.FC<AILayoutWizardProps> = ({
});
if (allContainers.length > 0) {
console.log('allContainers', allContainers);
onContainersGenerated(allContainers);
onClose();
toast.success(`Generated ${allContainers.length} container(s)`);
} else {
toast.error('AI did not produce any layout containers.');
toast.error(translate('AI did not produce any layout containers.'));
}
} catch (error: any) {
console.error('[AILayoutWizard] Generation error:', error);
toast.error('An error occurred during generation.');
toast.error(translate('An error occurred during generation.'));
} finally {
setIsGenerating(false);
}

View File

@ -64,9 +64,9 @@ export const EmailPreviewPanel = ({
</div>
{/* Preview Container */}
<div className="flex-1 overflow-auto flex justify-center p-4 md:p-8">
<div className="flex-1 min-h-0 overflow-auto flex justify-center p-4 md:p-8">
<div
className={`transition-all duration-300 bg-white shadow-lg overflow-hidden ${previewMode === 'mobile' ? 'w-[375px] h-[667px] rounded-3xl border-8 border-gray-800' : 'w-full h-full rounded-md'}`}
className={`transition-all duration-300 bg-white shadow-lg overflow-hidden ${previewMode === 'mobile' ? 'w-[375px] h-[667px] rounded-3xl border-8 border-gray-800' : 'w-full h-[calc(100vh-12rem)] rounded-md'}`}
>
<iframe
ref={iframeRef as any}

View File

@ -1,3 +1,4 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { T } from "@/i18n";
@ -7,6 +8,9 @@ interface SendEmailDialogProps {
onOpenChange: (open: boolean) => void;
emailRecipient: string;
onEmailRecipientChange: (value: string) => void;
emailSubject: string;
onEmailSubjectChange: (value: string) => void;
pageTitle?: string;
onSend: () => void;
isSending: boolean;
}
@ -16,25 +20,65 @@ export const SendEmailDialog = ({
onOpenChange,
emailRecipient,
onEmailRecipientChange,
emailSubject,
onEmailSubjectChange,
pageTitle,
onSend,
isSending,
}: SendEmailDialogProps) => {
const [includeTitle, setIncludeTitle] = useState(false);
const handleToggleTitle = (checked: boolean) => {
setIncludeTitle(checked);
if (checked && pageTitle) {
onEmailSubjectChange(`${pageTitle} ${emailSubject}`);
} else if (!checked && pageTitle && emailSubject.startsWith(pageTitle)) {
onEmailSubjectChange(emailSubject.slice(pageTitle.length).trimStart());
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle><T>Send Email Preview</T></DialogTitle>
</DialogHeader>
<div className="py-4">
<label className="block text-sm font-medium mb-2"><T>Recipient Email</T></label>
<input
type="email"
className="w-full p-2 border rounded-md bg-background"
placeholder="user@example.com"
value={emailRecipient}
onChange={(e) => onEmailRecipientChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSend()}
/>
<div className="py-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-2"><T>Subject</T></label>
<input
type="text"
className="w-full p-2 border rounded-md bg-background"
placeholder="Email subject..."
value={emailSubject}
onChange={(e) => onEmailSubjectChange(e.target.value)}
/>
</div>
{/* Options */}
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeTitle}
onChange={(e) => handleToggleTitle(e.target.checked)}
className="rounded"
/>
<T>Include page title</T>
</label>
</div>
<div>
<label className="block text-sm font-medium mb-2"><T>Recipient Email</T></label>
<input
type="email"
className="w-full p-2 border rounded-md bg-background"
placeholder="user@example.com"
value={emailRecipient}
onChange={(e) => onEmailRecipientChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onSend()}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}><T>Cancel</T></Button>

View File

@ -481,6 +481,7 @@ const UserPageEditInner = ({
onPaste: clipboardActions.handlePaste,
onTogglePreview: () => setIsPreview(prev => !prev),
selectedWidgetIds,
selectedContainerId,
onCommandPicker: () => setShowCommandPicker(true),
});
@ -727,6 +728,9 @@ const UserPageEditInner = ({
onOpenChange={emailActions.setShowSendEmailDialog}
emailRecipient={emailActions.emailRecipient}
onEmailRecipientChange={emailActions.setEmailRecipient}
emailSubject={emailActions.emailSubject}
onEmailSubjectChange={emailActions.setEmailSubject}
pageTitle={page.title}
onSend={emailActions.handleSendEmail}
isSending={emailActions.isSendingEmail}
/>

View File

@ -6,6 +6,7 @@ import { generateSchemaForType, generateUiSchemaForType, deepMergeUiSchema } fro
import { customWidgets, customTemplates } from '@/modules/types/RJSFTemplates';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { useLayout } from '@/modules/layout/LayoutContext';
import { UpdatePageMetaCommand } from '@/modules/layout/commands';
import { Accordion, AccordionContent, AccordionItem } from "@/components/ui/accordion";
@ -119,7 +120,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
return (
<div className="space-y-6 mt-8">
<h2 className="text-lg font-semibold border-b pb-2 mb-4">Type Properties</h2>
<h2 className="text-lg font-semibold border-b pb-2 mb-4"><T>Type Properties</T></h2>
<Accordion type="multiple" className="w-full" defaultValue={assignedTypes.map(t => t.id)} key={assignedTypes.map(t => t.id).join(',')}>
{assignedTypes.map(type => {
@ -203,7 +204,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b flex flex-row items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<DialogTitle>Edit Type: {editingType?.name}</DialogTitle>
<DialogTitle><T>Edit Type</T>: {editingType?.name}</DialogTitle>
</div>
<div className="flex items-center gap-2 mr-8">
<TypeEditorActions
@ -225,7 +226,7 @@ export const UserPageTypeFields: React.FC<UserPageTypeFieldsProps> = ({
onIsBuildingChange={setIsBuilding}
onSave={handleTypeSave}
onDeleteRaw={async () => {
toast.error("Deleting currently used types is not recommended here.");
toast.error(translate("Deleting currently used types is not recommended here."));
}}
/>
)}

View File

@ -3,7 +3,7 @@ import { toast } from "sonner";
import { translate } from "@/i18n";
import { useLayout } from "@/modules/layout/LayoutContext";
import { useSelection } from "@/modules/layout/SelectionContext";
import { PasteWidgetsCommand, AddContainerCommand } from "@/modules/layout/commands";
import { PasteWidgetsCommand, PasteContainersCommand } from "@/modules/layout/commands";
import { WidgetInstance, LayoutContainer as LayoutContainerType } from "@/modules/layout/LayoutManager";
interface UseClipboardActionsParams {
@ -77,21 +77,15 @@ export function useClipboardActions({
try {
if (clipboard.containers.length > 0) {
for (const container of clipboard.containers) {
const suffix = crypto.randomUUID().slice(0, 6);
const cloneContainer = (c: LayoutContainerType): LayoutContainerType => ({
...JSON.parse(JSON.stringify(c)),
id: `${c.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${suffix}`,
widgets: c.widgets.map(w => ({
...JSON.parse(JSON.stringify(w)),
id: `${w.id.replace(/-copy-[a-f0-9]+$/, '')}-copy-${crypto.randomUUID().slice(0, 6)}`
})),
children: c.children ? c.children.map(cloneContainer) : []
});
const newContainer = cloneContainer(container);
const cmd = new AddContainerCommand(effectivePageId, newContainer);
await executeCommand(cmd);
// Find insert position: after selected container, or end
let insertIndex = -1;
if (selectedContainerId) {
const idx = layout.containers.findIndex(c => c.id === selectedContainerId);
if (idx !== -1) insertIndex = idx;
}
const cmd = new PasteContainersCommand(effectivePageId, clipboard.containers, insertIndex);
await executeCommand(cmd);
toast.success(translate(`Pasted ${clipboard.containers.length} container(s)`));
} else if (clipboard.widgets.length > 0) {
let targetContainerId = selectedContainerId;

View File

@ -10,6 +10,7 @@ interface UseEditorKeyboardShortcutsParams {
onPaste: () => void;
onTogglePreview: () => void;
selectedWidgetIds: Set<string>;
selectedContainerId: string | null;
onCommandPicker?: () => void;
}
@ -23,6 +24,7 @@ export function useEditorKeyboardShortcuts({
onPaste,
onTogglePreview,
selectedWidgetIds,
selectedContainerId,
onCommandPicker,
}: UseEditorKeyboardShortcutsParams) {
// Undo/Redo shortcuts
@ -73,7 +75,7 @@ export function useEditorKeyboardShortcuts({
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
if (!isInput && selectedWidgetIds.size > 0) {
if (!isInput && (selectedWidgetIds.size > 0 || selectedContainerId)) {
e.preventDefault();
onCopy();
}
@ -88,5 +90,5 @@ export function useEditorKeyboardShortcuts({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onSave, onCopy, onPaste, onTogglePreview, selectedWidgetIds, onCommandPicker]);
}, [onSave, onCopy, onPaste, onTogglePreview, selectedWidgetIds, selectedContainerId, onCommandPicker]);
}

View File

@ -11,6 +11,10 @@ export function useEmailActions({ page, orgSlug }: UseEmailActionsParams) {
const [showEmailPreview, setShowEmailPreview] = useState(false);
const [showSendEmailDialog, setShowSendEmailDialog] = useState(false);
const [emailRecipient, setEmailRecipient] = useState('cgoflyn@gmail.com');
const [emailSubject, setEmailSubject] = useState(() => {
const now = new Date();
return `${now.toISOString().slice(0, 10)}::${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
});
const [isSendingEmail, setIsSendingEmail] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [previewMode, setPreviewMode] = useState<'desktop' | 'mobile'>('desktop');
@ -40,7 +44,7 @@ export function useEmailActions({ page, orgSlug }: UseEmailActionsParams) {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: emailRecipient })
body: JSON.stringify({ to: emailRecipient, subject: emailSubject || undefined })
});
if (!res.ok) {
@ -73,9 +77,18 @@ export function useEmailActions({ page, orgSlug }: UseEmailActionsParams) {
showEmailPreview,
setShowEmailPreview,
showSendEmailDialog,
setShowSendEmailDialog,
setShowSendEmailDialog: (open: boolean) => {
if (open) {
// Refresh default subject with current time when opening
const now = new Date();
setEmailSubject(`${now.toISOString().slice(0, 10)}::${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`);
}
setShowSendEmailDialog(open);
},
emailRecipient,
setEmailRecipient,
emailSubject,
setEmailSubject,
isSendingEmail,
authToken,
previewMode,

View File

@ -51,6 +51,7 @@ export function useTemplateManager({
}, [currentLayout?.rootTemplate]);
const loadTemplates = async () => {
console.log("Loading templates");
const { data } = await getLayouts({ type: 'canvas' });
if (data) {
setTemplates(data);

View File

@ -36,8 +36,12 @@ import {
BookmarkCheck,
Sparkles,
Globe,
Lock
Lock,
Languages,
Pencil
} from "lucide-react";
import { PageTranslationDialog } from '@/modules/i18n/PageTranslationDialog';
import type { WidgetInstance } from '@/modules/layout/LayoutManager';
import {
AlertDialog,
AlertDialogAction,
@ -60,6 +64,7 @@ import { Database as DatabaseType } from '@/integrations/supabase/types';
import { CategoryManager } from "@/components/widgets/CategoryManager";
import { VariablesEditor } from "@/components/variables/VariablesEditor";
import { widgetRegistry } from "@/lib/widgetRegistry";
import { iterateWidgets } from '@polymech/shared';
import { useWidgetSnippets, WidgetSnippetData } from '@/modules/layout/useWidgetSnippets';
import { PagePickerDialog } from "../../PagePickerDialog";
import { useLayout } from "@/modules/layout/LayoutContext";
@ -79,6 +84,7 @@ import { Page } from "../../types";
const SnippetsRibbonGroup: React.FC<{
onToggleWidget?: (widgetId: string, initialProps?: Record<string, any>) => void;
}> = ({ onToggleWidget }) => {
console.log("SnippetsRibbonGroup");
const { snippets, loading } = useWidgetSnippets();
if (loading || snippets.length === 0) return null;
@ -365,7 +371,8 @@ export const PageRibbonBar = ({
navigate('/types-editor');
}, [navigate]);
const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced'>('page');
const [activeTab, setActiveTab] = useState<'page' | 'widgets' | 'layouts' | 'view' | 'advanced' | 'i18n'>('page');
const [showTranslationDialog, setShowTranslationDialog] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
@ -562,10 +569,10 @@ export const PageRibbonBar = ({
const pageJson = JSON.stringify(pageDump, null, 2);
console.log('Page JSON:', pageJson);
await navigator.clipboard.writeText(pageJson);
toast.success("Page JSON dumped to console and clipboard");
toast.success(translate("Page JSON dumped to console and clipboard"));
} catch (e) {
console.error("Failed to dump JSON", e);
toast.error("Failed to dump JSON");
toast.error(translate("Failed to dump JSON"));
}
}, [page, getLoadedPageLayout]);
@ -596,6 +603,7 @@ export const PageRibbonBar = ({
<RibbonTab active={activeTab === 'layouts'} onClick={() => setActiveTab('layouts')}><T>LAYOUTS</T></RibbonTab>
<RibbonTab active={activeTab === 'view'} onClick={() => setActiveTab('view')}><T>VIEW</T></RibbonTab>
<RibbonTab active={activeTab === 'advanced'} onClick={() => setActiveTab('advanced')}><T>ADVANCED</T></RibbonTab>
<RibbonTab active={activeTab === 'i18n'} onClick={() => setActiveTab('i18n')}><T>I18N</T></RibbonTab>
</div>
</div>
@ -1067,6 +1075,86 @@ export const PageRibbonBar = ({
</RibbonGroup>
</>
)}
{/* === I18N TAB === */}
{activeTab === 'i18n' && (() => {
// Find the selected widget instance by traversing containers
const pageLayout = getLoadedPageLayout(`page-${page.id}`);
let selectedWidget: WidgetInstance | null = null;
if (pageLayout && selectedWidgetId) {
iterateWidgets(pageLayout as any, (w) => {
if (w.id === selectedWidgetId) { selectedWidget = w as WidgetInstance; }
});
}
const isTranslatable = selectedWidget && (
selectedWidget.widgetId === 'markdown-text' ||
selectedWidget.widgetId === 'html-block'
);
const widgetContent = selectedWidget?.props?.content || '';
const widgetName = selectedWidget ? widgetRegistry.get(selectedWidget.widgetId)?.metadata.name || selectedWidget.widgetId : null;
return (
<>
<RibbonGroup label="Selected Widget">
<div className="flex flex-col justify-center px-3 gap-0.5 h-full min-w-[8rem]">
{selectedWidget ? (
<>
<div className="text-xs font-medium">{widgetName}</div>
<div className="text-[10px] font-mono text-muted-foreground">{selectedWidgetId?.slice(0, 12)}</div>
{isTranslatable ? (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium"><T>Translatable</T></span>
) : (
<span className="text-[10px] text-amber-600 dark:text-amber-400"><T>Not translatable</T></span>
)}
</>
) : (
<div className="text-xs text-muted-foreground italic"><T>No widget selected</T></div>
)}
</div>
</RibbonGroup>
<RibbonGroup label="Translate">
<CompactFlowGroup
maxColumns={2}
actions={[
{
icon: Languages,
label: 'Translate',
onClick: () => setShowTranslationDialog(true),
disabled: !isTranslatable || !widgetContent,
iconColor: 'text-blue-600 dark:text-blue-400',
},
{
icon: Pencil,
label: 'Edit Translations',
onClick: () => setShowTranslationDialog(true),
disabled: !selectedWidget,
iconColor: 'text-purple-600 dark:text-purple-400',
},
]}
/>
</RibbonGroup>
<RibbonGroup label="Page">
<CompactFlowGroup
maxColumns={1}
actions={[
{
icon: Globe,
label: 'All Page Translations',
onClick: () => {
// Open dialog without widget filter to show all page translations
setShowTranslationDialog(true);
},
iconColor: 'text-green-600 dark:text-green-400',
},
]}
/>
</RibbonGroup>
</>
);
})()}
</div>
</div>
@ -1174,6 +1262,28 @@ export const PageRibbonBar = ({
forbiddenIds={[page.id]}
/>
{showTranslationDialog && (() => {
// Find selected widget once for all props
const pageLayout = getLoadedPageLayout(`page-${page.id}`);
let selectedWidget: WidgetInstance | null = null;
if (pageLayout && selectedWidgetId) {
iterateWidgets(pageLayout as any, (w) => {
if (w.id === selectedWidgetId) { selectedWidget = w as WidgetInstance; }
});
}
return (
<PageTranslationDialog
open={showTranslationDialog}
onOpenChange={setShowTranslationDialog}
pageId={page.id}
pageTitle={page.title}
widgetInstanceId={selectedWidgetId}
widgetTypeId={selectedWidget?.widgetId}
sourceContent={selectedWidget?.props?.content}
/>
);
})()}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>

View File

@ -269,28 +269,33 @@ export const fetchMediaItemsByIds = async (
): Promise<MediaItem[]> => {
if (!ids || ids.length === 0) return [];
// Build query parameters
const params = new URLSearchParams({
ids: ids.join(','),
const sortedIds = [...ids].sort();
const key = `pictures-batch-${sortedIds.join(',')}${options?.maintainOrder ? '-ordered' : ''}`;
return fetchWithDeduplication(key, async () => {
// Build query parameters
const params = new URLSearchParams({
ids: ids.join(','),
});
if (options?.maintainOrder) {
params.append('maintainOrder', 'true');
}
// Call server API endpoint
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
const response = await fetch(`${serverUrl}/api/media-items?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch media items: ${response.statusText}`);
}
const data = await response.json();
// The server returns raw Supabase data, so we need to adapt it
const { adaptSupabasePicturesToMediaItems } = await import('@/pages/Post/adapters');
return adaptSupabasePicturesToMediaItems(data);
});
if (options?.maintainOrder) {
params.append('maintainOrder', 'true');
}
// Call server API endpoint
const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL;
const response = await fetch(`${serverUrl}/api/media-items?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch media items: ${response.statusText}`);
}
const data = await response.json();
// The server returns raw Supabase data, so we need to adapt it
const { adaptSupabasePicturesToMediaItems } = await import('@/pages/Post/adapters');
return adaptSupabasePicturesToMediaItems(data);
};
/**
* Filters out pictures that belong to private collections the user doesn't have access to

View File

@ -26,6 +26,9 @@ export const fetchPostDetailsAPI = async (id: string, options: { sizes?: string,
if (options.sizes) params.set('sizes', options.sizes);
if (options.formats) params.set('formats', options.formats);
const { getCurrentLang } = await import('@/i18n');
params.set('lang', getCurrentLang());
const qs = params.toString();
const url = `/api/posts/${id}${qs ? `?${qs}` : ''}`;

View File

@ -9,7 +9,7 @@ interface TypeEditorActionsProps {
}
export const TypeEditorActions: React.FC<TypeEditorActionsProps> = ({
actionIds = ['types.new', 'types.edit.visual', 'types.preview.toggle', 'types.delete', 'types.save'],
actionIds = ['types.new', 'types.edit.visual', 'types.preview.toggle', 'types.translate', 'types.delete', 'types.save'],
className
}) => {
const { actions, executeAction } = useActions();

View File

@ -2,11 +2,13 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { TypeDefinition, updateType, createType } from './client-types';
import { Card } from '@/components/ui/card';
import { toast } from "sonner";
import { T, translate } from '@/i18n';
import { TypeBuilder, BuilderOutput, BuilderElement, BuilderMode, TypeBuilderRef } from './TypeBuilder';
import { TypeRenderer, TypeRendererRef } from './TypeRenderer';
import { RefreshCw, Save, Trash2, X, Play } from "lucide-react";
import { RefreshCw, Save, Trash2, X, Play, Languages } from "lucide-react";
import { useActions } from '@/actions/useActions';
import { Action } from '@/actions/types';
import { TypeTranslationDialog } from '@/modules/i18n/TypeTranslationDialog';
export interface TypesEditorProps {
types: TypeDefinition[];
@ -26,6 +28,7 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
onDeleteRaw
}) => {
const [builderInitialData, setBuilderInitialData] = useState<BuilderOutput | undefined>(undefined);
const [showTranslationDialog, setShowTranslationDialog] = useState(false);
const rendererRef = useRef<TypeRendererRef>(null);
const builderRef = useRef<TypeBuilderRef>(null);
const { registerAction, updateAction, unregisterAction } = useActions();
@ -97,11 +100,11 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
json_schema: jsonSchema,
meta: { ...selectedType.meta, uiSchema }
});
toast.success("Type updated");
toast.success(translate("Type updated"));
onSave();
} catch (e) {
console.error(e);
toast.error("Failed to update type");
toast.error(translate("Failed to update type"));
}
}, [selectedType, onSave]);
@ -170,13 +173,13 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
});
}
toast.success("Type updated");
toast.success(translate("Type updated"));
setBuilderInitialData(undefined);
onIsBuildingChange(false);
onSave();
} catch (error) {
console.error("Failed to update type", error);
toast.error("Failed to update type");
toast.error(translate("Failed to update type"));
}
} else {
// Creating new type
@ -216,13 +219,13 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
}
await createType(newType as any);
toast.success("Type created successfully");
toast.success(translate("Type created successfully"));
setBuilderInitialData(undefined);
onIsBuildingChange(false);
onSave();
} catch (error) {
console.error("Failed to create type", error);
toast.error("Failed to create type");
toast.error(translate("Failed to create type"));
}
}
}, [selectedType, types, onIsBuildingChange, onSave]);
@ -250,14 +253,14 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
icon: Trash2,
group: 'types',
handler: async () => {
if (selectedType && confirm("Are you sure you want to delete this type?")) {
if (selectedType && confirm(translate("Are you sure you want to delete this type?"))) {
try {
await onDeleteRaw(selectedType.id);
toast.success("Type deleted");
toast.success(translate("Type deleted"));
onSave(); // Reload
} catch (e) {
console.error(e);
toast.error("Failed to delete type");
toast.error(translate("Failed to delete type"));
}
}
}
@ -287,6 +290,15 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
rendererRef.current?.triggerPreview();
},
metadata: { active: false }
},
{
id: 'types.translate',
label: translate('Translate'),
icon: Languages,
group: 'types',
handler: () => {
setShowTranslationDialog(true);
}
}
];
@ -309,6 +321,10 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
updateAction('types.preview.toggle', {
visible: !isBuilding && selectedType?.kind === 'structure'
});
updateAction('types.translate', {
visible: !isBuilding && !!selectedType,
disabled: !selectedType
});
}, [selectedType, isBuilding, updateAction]);
if (isBuilding) {
@ -343,10 +359,36 @@ export const TypesEditor: React.FC<TypesEditorProps> = ({
<div className="bg-muted p-4 rounded-full mb-2">
<RefreshCw className="h-8 w-8 opacity-20" />
</div>
<h3 className="text-lg font-medium">No Type Selected</h3>
<p className="max-w-sm text-sm">Select a type from the sidebar to view its details, edit the schema, and preview the generated form.</p>
<h3 className="text-lg font-medium"><T>No Type Selected</T></h3>
<p className="max-w-sm text-sm"><T>Select a type from the sidebar to view its details, edit the schema, and preview the generated form.</T></p>
</div>
)}
{/* Type Translation Dialog */}
{showTranslationDialog && selectedType && (
<TypeTranslationDialog
open={showTranslationDialog}
onOpenChange={setShowTranslationDialog}
typeId={selectedType.id}
typeName={selectedType.name}
typeDescription={selectedType.description || undefined}
structureFields={
selectedType.kind === 'structure' && selectedType.structure_fields
? selectedType.structure_fields
.sort((a, b) => a.order - b.order)
.map(sf => {
const fieldType = types.find(t => t.id === sf.field_type_id);
const schemaProps = selectedType.json_schema?.properties?.[sf.field_name];
return {
fieldName: sf.field_name,
title: schemaProps?.title || sf.field_name,
description: schemaProps?.description || fieldType?.description || undefined,
};
})
: undefined
}
/>
)}
</Card>
);
};

View File

@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { fetchTypes, deleteType, TypeDefinition } from './client-types';
import { Loader2, Plus } from "lucide-react";
import { toast } from "sonner";
import { T, translate } from '@/i18n';
import { TypesList } from './TypesList';
import { TypesEditor } from './TypesEditor';
import { useActions } from '@/actions/useActions';
@ -29,7 +30,7 @@ const TypesPlayground: React.FC = () => {
}
} catch (error) {
console.error("Failed to fetch types", error);
toast.error("Failed to load types");
toast.error(translate("Failed to load types"));
} finally {
setLoading(false);
}
@ -84,9 +85,9 @@ const TypesPlayground: React.FC = () => {
{/* Header */}
<div className="border-b px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Types Editor</h1>
<h1 className="text-2xl font-bold"><T>Types Editor</T></h1>
<p className="text-sm text-muted-foreground mt-1">
Manage and preview your type definitions
<T>Manage and preview your type definitions</T>
</p>
</div>
<TypeEditorActions
@ -94,6 +95,7 @@ const TypesPlayground: React.FC = () => {
'types.new',
'types.edit.visual',
'types.preview.toggle',
'types.translate',
'types.delete',
'types.cancel',
'types.save'

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import UserManager from "@/components/admin/UserManager";
import StorageManager from "@/components/admin/StorageManager";
import { useAuth } from "@/hooks/useAuth";
import { T } from "@/i18n";
import { T, translate } from "@/i18n";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { Button } from "@/components/ui/button";
@ -47,7 +47,7 @@ const AdminPage = () => {
<Route path="bans" element={<BansSection session={session} />} />
<Route path="violations" element={<ViolationsSection session={session} />} />
<Route path="analytics" element={
<Suspense fallback={<div>Loading analytics...</div>}>
<Suspense fallback={<div><T>Loading analytics...</T></div>}>
<AnalyticsDashboard />
</Suspense>
} />
@ -62,15 +62,15 @@ const AdminPage = () => {
const UserManagerSection = () => (
<div>
<h1 className="text-2xl font-bold mb-4">User Management</h1>
<h1 className="text-2xl font-bold mb-4"><T>User Management</T></h1>
<UserManager />
</div>
);
const DashboardSection = () => (
<div>
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<p>Welcome to the admin dashboard. More features coming soon!</p>
<h1 className="text-2xl font-bold mb-4"><T>Dashboard</T></h1>
<p><T>Welcome to the admin dashboard. More features coming soon!</T></p>
</div>
);
@ -100,11 +100,11 @@ const ServerSection = ({ session }: { session: any }) => {
throw new Error(err.error || 'Failed to flush cache');
}
toast.success("Cache flushed successfully", {
description: "Access and Content caches have been cleared."
toast.success(translate("Cache flushed successfully"), {
description: translate("Access and Content caches have been cleared.")
});
} catch (err: any) {
toast.error("Failed to flush cache", {
toast.error(translate("Failed to flush cache"), {
description: err.message
});
} finally {
@ -113,7 +113,7 @@ const ServerSection = ({ session }: { session: any }) => {
};
const handleRestart = async () => {
if (!confirm('Are you sure you want to restart the server? This will cause a brief downtime.')) {
if (!confirm(translate('Are you sure you want to restart the server? This will cause a brief downtime.'))) {
return;
}
@ -131,11 +131,11 @@ const ServerSection = ({ session }: { session: any }) => {
}
const data = await res.json();
toast.success("Server restarting", {
toast.success(translate("Server restarting"), {
description: data.message
});
} catch (err: any) {
toast.error("Failed to restart server", {
toast.error(translate("Failed to restart server"), {
description: err.message
});
}
@ -145,17 +145,16 @@ const ServerSection = ({ session }: { session: any }) => {
<div>
<div className="flex items-center gap-2 mb-6">
<Server className="h-6 w-6" />
<h1 className="text-2xl font-bold">Server Management</h1>
<h1 className="text-2xl font-bold"><T>Server Management</T></h1>
</div>
<div className="bg-card border rounded-lg p-6 max-w-2xl">
<h2 className="text-lg font-semibold mb-4">Cache Control</h2>
<h2 className="text-lg font-semibold mb-4"><T>Cache Control</T></h2>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Flush System Cache</p>
<p className="font-medium"><T>Flush System Cache</T></p>
<p className="text-sm text-muted-foreground mt-1">
Clears the server-side content cache (memory) and the disk-based image cache.
Use this if content is not updating correctly.
<T>Clears the server-side content cache (memory) and the disk-based image cache. Use this if content is not updating correctly.</T>
</p>
</div>
@ -165,19 +164,18 @@ const ServerSection = ({ session }: { session: any }) => {
variant="destructive"
>
{loading && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Flush Cache
<T>Flush Cache</T>
</Button>
</div>
</div>
<div className="bg-card border rounded-lg p-6 max-w-2xl mt-6">
<h2 className="text-lg font-semibold mb-4">System Control</h2>
<h2 className="text-lg font-semibold mb-4"><T>System Control</T></h2>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Restart Server</p>
<p className="font-medium"><T>Restart Server</T></p>
<p className="text-sm text-muted-foreground mt-1">
Gracefully restart the server process. Systemd will automatically bring it back online.
This will cause a brief downtime.
<T>Gracefully restart the server process. Systemd will automatically bring it back online. This will cause a brief downtime.</T>
</p>
</div>
@ -186,7 +184,7 @@ const ServerSection = ({ session }: { session: any }) => {
variant="destructive"
>
<Power className="mr-2 h-4 w-4" />
Restart
<T>Restart</T>
</Button>
</div>
</div>

View File

@ -8,16 +8,20 @@ import { useOrganization } from '@/contexts/OrganizationContext';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { Github, Mail } from 'lucide-react';
import { T, translate } from '@/i18n';
type AuthMode = 'signIn' | 'signUp' | 'forgotPassword';
const Auth = () => {
const [isSignUp, setIsSignUp] = useState(false);
const [mode, setMode] = useState<AuthMode>('signIn');
const [resetSent, setResetSent] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: '',
username: '',
displayName: ''
});
const { user, signUp, signIn, signInWithGithub, signInWithGoogle } = useAuth();
const { user, signUp, signIn, signInWithGithub, signInWithGoogle, resetPassword } = useAuth();
const { orgSlug, isOrgContext } = useOrganization();
const { toast } = useToast();
const navigate = useNavigate();
@ -40,8 +44,8 @@ const Auth = () => {
if (!formData.email || !formData.password || !formData.username || !formData.displayName) {
toast({
variant: 'destructive',
title: 'Missing Information',
description: 'Please fill in all fields'
title: translate('Missing Information'),
description: translate('Please fill in all fields')
});
return;
}
@ -73,8 +77,8 @@ const Auth = () => {
if (memberError) throw memberError;
toast({
title: 'Welcome!',
description: `You've been added to the organization.`
title: translate('Welcome!'),
description: translate("You've been added to the organization.")
});
}
} catch (error) {
@ -88,119 +92,198 @@ const Auth = () => {
if (!formData.email || !formData.password) {
toast({
variant: 'destructive',
title: 'Missing Information',
description: 'Please fill in email and password'
title: translate('Missing Information'),
description: translate('Please fill in email and password')
});
return;
}
await signIn(formData.email, formData.password);
};
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.email) {
toast({
variant: 'destructive',
title: translate('Missing Information'),
description: translate('Please enter your email address')
});
return;
}
const { error } = await resetPassword(formData.email);
if (!error) {
setResetSent(true);
}
};
const getTitle = () => {
if (mode === 'forgotPassword') return translate('Reset Password');
if (mode === 'signUp') return translate('Create Account');
return translate('Welcome Back');
};
const getDescription = () => {
if (mode === 'forgotPassword') return translate('Enter your email to receive a reset link');
if (mode === 'signUp') return translate('Join our photo sharing community');
return translate('Sign in to your account');
};
const getSubmitHandler = () => {
if (mode === 'forgotPassword') return handleForgotPassword;
if (mode === 'signUp') return handleSignUp;
return handleSignIn;
};
return (
<div className="min-h-screen bg-gradient-to-br from-background via-secondary/20 to-accent/20 flex items-center justify-center p-4">
<Card className="w-full max-w-md glass-morphism border-white/20">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">
{isSignUp ? 'Create Account' : 'Welcome Back'}
{getTitle()}
</CardTitle>
<CardDescription>
{isSignUp ? 'Join our photo sharing community' : 'Sign in to your account'}
{getDescription()}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={isSignUp ? handleSignUp : handleSignIn} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<Input
type="email"
placeholder="Enter your email"
value={formData.email}
onChange={handleInputChange('email')}
required
/>
{mode === 'forgotPassword' && resetSent ? (
<div className="space-y-4 text-center">
<p className="text-sm text-muted-foreground">
<T>If an account exists for</T> <strong>{formData.email}</strong>, <T>a reset link has been sent. Check your inbox.</T>
</p>
<Button
variant="ghost"
onClick={() => { setMode('signIn'); setResetSent(false); }}
className="text-sm"
>
<T>Back to sign in</T>
</Button>
</div>
{isSignUp && (
<>
) : (
<>
<form onSubmit={getSubmitHandler()} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<label className="text-sm font-medium"><T>Email</T></label>
<Input
placeholder="Choose a username"
value={formData.username}
onChange={handleInputChange('username')}
type="email"
placeholder={translate('Enter your email')}
value={formData.email}
onChange={handleInputChange('email')}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Display Name</label>
<Input
placeholder="Your display name"
value={formData.displayName}
onChange={handleInputChange('displayName')}
required
/>
{mode === 'signUp' && (
<>
<div className="space-y-2">
<label className="text-sm font-medium"><T>Username</T></label>
<Input
placeholder={translate('Choose a username')}
value={formData.username}
onChange={handleInputChange('username')}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"><T>Display Name</T></label>
<Input
placeholder={translate('Your display name')}
value={formData.displayName}
onChange={handleInputChange('displayName')}
required
/>
</div>
</>
)}
{mode !== 'forgotPassword' && (
<div className="space-y-2">
<label className="text-sm font-medium"><T>Password</T></label>
<Input
type="password"
placeholder={mode === 'signUp' ? translate('Create a password') : translate('Enter your password')}
value={formData.password}
onChange={handleInputChange('password')}
required
/>
</div>
)}
<Button type="submit" className="w-full">
{mode === 'forgotPassword' ? translate('Send Reset Link') : mode === 'signUp' ? translate('Create Account') : translate('Sign In')}
</Button>
</form>
{mode === 'signIn' && (
<div className="mt-2 text-center">
<Button
variant="link"
onClick={() => setMode('forgotPassword')}
className="text-xs text-muted-foreground p-0 h-auto"
>
<T>Forgot password?</T>
</Button>
</div>
</>
)}
)}
<div className="space-y-2">
<label className="text-sm font-medium">Password</label>
<Input
type="password"
placeholder={isSignUp ? "Create a password" : "Enter your password"}
value={formData.password}
onChange={handleInputChange('password')}
required
/>
</div>
{mode !== 'forgotPassword' && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
<T>Or continue with</T>
</span>
</div>
</div>
<Button type="submit" className="w-full">
{isSignUp ? 'Create Account' : 'Sign In'}
</Button>
</form>
<div className="grid grid-cols-2 gap-4">
<Button
type="button"
variant="outline"
onClick={signInWithGithub}
>
<Github className="mr-2 h-4 w-4" />
GitHub
</Button>
<Button
type="button"
variant="outline"
onClick={signInWithGoogle}
>
<Mail className="mr-2 h-4 w-4" />
Google
</Button>
</div>
</>
)}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Button
type="button"
variant="outline"
onClick={signInWithGithub}
>
<Github className="mr-2 h-4 w-4" />
GitHub
</Button>
<Button
type="button"
variant="outline"
onClick={signInWithGoogle}
>
<Mail className="mr-2 h-4 w-4" />
Google
</Button>
</div>
<div className="mt-6 text-center">
<Button
variant="ghost"
onClick={() => setIsSignUp(!isSignUp)}
className="text-sm"
>
{isSignUp
? 'Already have an account? Sign in'
: "Don't have an account? Sign up"
}
</Button>
</div>
<div className="mt-6 text-center">
{mode === 'forgotPassword' ? (
<Button
variant="ghost"
onClick={() => setMode('signIn')}
className="text-sm"
>
<T>Back to sign in</T>
</Button>
) : (
<Button
variant="ghost"
onClick={() => setMode(mode === 'signUp' ? 'signIn' : 'signUp')}
className="text-sm"
>
{mode === 'signUp'
? translate('Already have an account? Sign in')
: translate("Don't have an account? Sign up")
}
</Button>
)}
</div>
</>
)}
</CardContent>
</Card>
</div>

View File

@ -13,6 +13,8 @@ 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";
import Footer from "@/components/Footer";
import { T } from "@/i18n";
const SIDEBAR_KEY = 'categorySidebarSize';
const DEFAULT_SIDEBAR = 15;
@ -95,17 +97,17 @@ const Index = () => {
<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
<T>Latest</T>
</ToggleGroupItem>
<ToggleGroupItem value="top" aria-label="Top Posts" size={size}>
<TrendingUp className="h-4 w-4 mr-2" />
Top
<T>Top</T>
</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
<T>Categories</T>
</ToggleGroupItem>
</ToggleGroup>
{renderCategoryBreadcrumb()}
@ -139,8 +141,8 @@ const Index = () => {
<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>
<SheetTitle className="text-sm"><T>Categories</T></SheetTitle>
<SheetDescription className="sr-only"><T>Browse categories</T></SheetDescription>
</SheetHeader>
<div className="overflow-y-auto flex-1">
<CategoryTreeView onNavigate={closeSheet} filterType="pages" />
@ -197,7 +199,7 @@ const Index = () => {
>
<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>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"><T>Categories</T></span>
</div>
<CategoryTreeView filterType="pages" />
</div>
@ -231,6 +233,7 @@ const Index = () => {
</div>
)}
</div>
<Footer />
</div>
);
};

View File

@ -14,8 +14,8 @@ import MarkdownRenderer from "@/components/MarkdownRenderer";
import Comments from "@/components/Comments";
import MagicWizardButton from "@/components/MagicWizardButton";
import { InlineDropZone } from "@/components/InlineDropZone";
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import { Loader2 } from 'lucide-react';
const VidstackPlayer = React.lazy(() => import('@/player/components/VidstackPlayerImpl').then(m => ({ default: m.VidstackPlayerImpl })));
import { PostRendererProps } from '../types';
import { isVideoType, normalizeMediaType } from "@/lib/mediaRegistry";
import { getVideoUrlWithResolution } from "../utils";
@ -354,17 +354,16 @@ export const ArticleRenderer: React.FC<PostRendererProps> = (props) => {
</div>
) : (
<div className="aspect-video">
<MediaPlayer
title={item.title}
src={itemVideoUrl}
poster={item.thumbnail_url}
className="w-full h-full"
controls
playsInline
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
<React.Suspense fallback={<div className="w-full h-full bg-black flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-white" /></div>}>
<VidstackPlayer
title={item.title}
src={itemVideoUrl}
poster={item.thumbnail_url}
className="w-full h-full"
controls
playsInline
/>
</React.Suspense>
</div>
)
) : (

View File

@ -1,15 +1,11 @@
import React from 'react';
import { Heart, Maximize, Share2, Grid } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { T } from "@/i18n";
import MarkdownRenderer from "@/components/MarkdownRenderer";
import { MediaType } from "@/types";
import { isVideoType, detectMediaType, normalizeMediaType } from "@/lib/mediaRegistry";
import { getVideoUrlWithResolution } from "../utils";
import ResponsiveImage from "@/components/ResponsiveImage";
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import '@vidstack/react/player/styles/default/theme.css';
import { Loader2 } from 'lucide-react';
const VidstackPlayer = React.lazy(() => import('@/player/components/VidstackPlayerImpl').then(m => ({ default: m.VidstackPlayerImpl })));
import { PostRendererProps } from '../types';
export const EmbedRenderer: React.FC<PostRendererProps> = (props) => {
@ -46,16 +42,15 @@ export const EmbedRenderer: React.FC<PostRendererProps> = (props) => {
</div>
) : (
<div className="w-full h-full">
<MediaPlayer
title={currentItem.title}
src={videoUrl}
poster={currentItem.thumbnail_url}
controls
className="w-full h-full"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
<React.Suspense fallback={<div className="w-full h-full bg-black flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-white" /></div>}>
<VidstackPlayer
title={currentItem.title}
src={videoUrl}
poster={currentItem.thumbnail_url}
controls
className="w-full h-full"
/>
</React.Suspense>
</div>
)
) : (

View File

@ -1,9 +1,6 @@
import React, { useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from 'lucide-react';
import { MediaPlayer, MediaProvider, type MediaPlayerInstance } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import '@vidstack/react/player/styles/default/theme.css';
import React, { useEffect } from "react";
import { Loader2 } from 'lucide-react';
const VidstackPlayer = React.lazy(() => import('@/player/components/VidstackPlayerImpl').then(m => ({ default: m.VidstackPlayerImpl })));
import ResponsiveImage from "@/components/ResponsiveImage";
import { PostMediaItem } from "../../types";
import { getTikTokVideoId, getYouTubeVideoId, isTikTokUrl } from "@/utils/mediaUtils";
@ -47,17 +44,12 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
imageFit = 'cover',
zoomEnabled = false
}) => {
const playerRef = useRef<MediaPlayerInstance>(null);
const [externalVideoState, setExternalVideoState] = React.useState<Record<string, boolean>>({});
// Cleanup video player
useEffect(() => {
// Stop any background videos
window.dispatchEvent(new CustomEvent('stop-video', { detail: { sourceId: 'compact-media-viewer' } }));
return () => {
playerRef.current?.pause();
};
}, []);
// Version Control Logic
@ -124,19 +116,17 @@ export const CompactMediaViewer: React.FC<CompactMediaViewerProps> = ({
</div>
) : (
<div className="w-full h-full">
<MediaPlayer
ref={playerRef}
title={mediaItem.title}
src={videoPlaybackUrl}
poster={videoPosterUrl}
load="idle"
posterLoad="eager"
controls
className="w-full h-full"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
<React.Suspense fallback={<div className="w-full h-full bg-black flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-white" /></div>}>
<VidstackPlayer
title={mediaItem.title}
src={videoPlaybackUrl}
poster={videoPosterUrl}
load="idle"
posterLoad="eager"
controls
className="w-full h-full"
/>
</React.Suspense>
</div>
)
) : (

View File

@ -8,9 +8,8 @@ import { formatDate, isLikelyFilename } from "@/utils/textUtils";
import { isVideoType, detectMediaType, normalizeMediaType } from "@/lib/mediaRegistry";
import { getVideoUrlWithResolution } from "../../utils";
import { getTikTokVideoId, getYouTubeVideoId, isTikTokUrl } from "@/utils/mediaUtils";
import { MediaPlayer, MediaProvider } from '@vidstack/react';
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
import '@vidstack/react/player/styles/default/theme.css';
import { Loader2 } from 'lucide-react';
const VidstackPlayer = React.lazy(() => import('@/player/components/VidstackPlayerImpl').then(m => ({ default: m.VidstackPlayerImpl })));
import ResponsiveImage from "@/components/ResponsiveImage";
import { Button } from "@/components/ui/button";
import { Maximize, ExternalLink, Play } from 'lucide-react';
@ -115,15 +114,14 @@ export const MobileGroupItem: React.FC<MobileGroupItemProps> = ({
<iframe src={item.image_url} className="h-full aspect-[9/16] border-0" allow="encrypted-media;"></iframe>
</div>
) : (
<MediaPlayer
title={item.title}
src={itemVideoUrl}
poster={item.thumbnail_url}
className="w-full h-full"
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
<React.Suspense fallback={<div className="w-full h-full bg-black flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-white" /></div>}>
<VidstackPlayer
title={item.title}
src={itemVideoUrl}
poster={item.thumbnail_url}
className="w-full h-full"
/>
</React.Suspense>
)
) : (
<ResponsiveImage

View File

@ -32,6 +32,7 @@ import {
SidebarProvider,
useSidebar
} from "@/components/ui/sidebar";
import { AnalyticsDashboard } from "./analytics";
const LazyPurchasesList = React.lazy(() =>
import("@polymech/ecommerce").then(m => ({ default: m.PurchasesList }))
@ -40,7 +41,7 @@ const LazyPurchasesList = React.lazy(() =>
type ActiveSection = 'general' | 'api-keys' | 'variables' | 'addresses' | 'vendor' | 'gallery' | 'purchases';
const Profile = () => {
const { user, loading } = useAuth();
const { user, loading, resetPassword } = useAuth();
const navigate = useNavigate();
const [images, setImages] = useState<ImageFile[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
@ -220,7 +221,7 @@ const Profile = () => {
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
<div className="text-muted-foreground"><T>Loading...</T></div>
</div>
);
}
@ -277,7 +278,7 @@ const Profile = () => {
<div className="space-y-6">
{/* Upload New Image */}
<div className="space-y-3">
<h4 className="font-medium">Upload New Image</h4>
<h4 className="font-medium"><T>Upload New Image</T></h4>
<div className="flex items-center gap-2">
<Button
variant="outline"
@ -286,7 +287,7 @@ const Profile = () => {
>
<label htmlFor="avatar-upload" className="cursor-pointer">
<Upload className="h-4 w-4 mr-2" />
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
{uploadingAvatar ? translate('Uploading...') : translate('Choose File')}
<input
id="avatar-upload"
type="file"
@ -297,7 +298,7 @@ const Profile = () => {
</label>
</Button>
<span className="text-sm text-muted-foreground">
Max 5MB, JPG/PNG/WebP
<T>Max 5MB, JPG/PNG/WebP</T>
</span>
</div>
</div>
@ -305,7 +306,7 @@ const Profile = () => {
{/* Select from Gallery */}
{images.length > 0 && (
<div className="space-y-3">
<h4 className="font-medium">Select from Gallery</h4>
<h4 className="font-medium"><T>Select from Gallery</T></h4>
<div className="grid grid-cols-4 gap-3 max-h-64 overflow-y-auto">
{images.map((image) => (
<button
@ -400,6 +401,23 @@ const Profile = () => {
</p>
</div>
{/* Change Password */}
<div className="space-y-2 pt-4 border-t">
<Label className="flex items-center gap-2">
<Key className="h-4 w-4" />
<T>Password</T>
</Label>
<p className="text-sm text-muted-foreground">
<T>Send a password reset link to your email address</T>
</p>
<Button
variant="outline"
onClick={() => resetPassword(user.email || email)}
>
<T>Change Password</T>
</Button>
</div>
<Button
onClick={() => {
handleProfileUpdate();
@ -570,7 +588,7 @@ const Profile = () => {
<CardTitle><T>My Purchases</T></CardTitle>
</CardHeader>
<CardContent>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground">Loading...</div>}>
<React.Suspense fallback={<div className="flex items-center justify-center py-12 text-muted-foreground"><T>Loading...</T></div>}>
<LazyPurchasesList
onFetchTransactions={async () => {
const { listTransactions } = await import('@/modules/ecommerce/client-ecommerce');
@ -584,27 +602,16 @@ const Profile = () => {
</Card>
)}
{activeSection === 'analytics' && roles.includes('admin') && (
<Card>
<CardHeader>
<CardTitle><T>Analytics</T></CardTitle>
</CardHeader>
<CardContent>
<AnalyticsDashboard />
</CardContent>
</Card>
)}
{activeSection === 'gallery' && (
<Card>
<CardHeader>
<CardTitle>My Gallery</CardTitle>
<CardTitle><T>My Gallery</T></CardTitle>
</CardHeader>
<CardContent>
{fetchingImages ? (
<div className="flex items-center justify-center py-16">
<div className="text-muted-foreground">Loading your images...</div>
<div className="text-muted-foreground"><T>Loading your images...</T></div>
</div>
) : (
<ImageGallery

View File

@ -1,17 +1,11 @@
/**
* Provider Settings Page
* Full page for managing AI provider configurations
*/
import React from 'react';
import { ProviderManagement } from '@/components/filters/ProviderManagement';
import { Card } from '@/components/ui/card';
import { T } from '@/i18n';
const ProviderSettings: React.FC = () => {
return (
<div className="min-h-screen bg-background">
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="space-y-6">
{/* Page Header */}

View File

@ -0,0 +1,101 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
const UpdatePassword = () => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast({
variant: 'destructive',
title: 'Passwords do not match',
description: 'Please make sure your passwords match.',
});
return;
}
if (password.length < 6) {
toast({
variant: 'destructive',
title: 'Password too short',
description: 'Password must be at least 6 characters.',
});
return;
}
setLoading(true);
const { error } = await supabase.auth.updateUser({ password });
setLoading(false);
if (error) {
toast({
variant: 'destructive',
title: 'Update failed',
description: error.message,
});
} else {
toast({
title: 'Password updated',
description: 'Your password has been changed successfully.',
});
navigate('/');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-background via-secondary/20 to-accent/20 flex items-center justify-center p-4">
<Card className="w-full max-w-md glass-morphism border-white/20">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold bg-gradient-primary bg-clip-text text-transparent">
Update Password
</CardTitle>
<CardDescription>Enter your new password below.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">New Password</label>
<Input
type="password"
placeholder="Enter new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Confirm Password</label>
<Input
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Updating...' : 'Update Password'}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default UpdatePassword;

View File

@ -6,7 +6,7 @@
import React, { useRef, useEffect, lazy, Suspense } from 'react';
import { VideoItem } from '../types';
import type { MediaPlayerInstance } from '@vidstack/react';
import { defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
// Lazy load Vidstack implementation
const VidstackPlayer = lazy(() => import('./VidstackPlayerImpl').then(module => ({ default: module.VidstackPlayerImpl })));
@ -76,7 +76,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
'--media-object-position': 'center'
} as any}
layoutProps={{
icons: defaultLayoutIcons,
noScrubGesture: true
}}
/>

View File

@ -9,9 +9,8 @@ import '@vidstack/react/player/styles/default/layouts/video.css';
interface VidstackPlayerImplProps extends Omit<MediaPlayerProps, 'children'> {
children?: React.ReactNode;
layoutProps?: {
icons: typeof defaultLayoutIcons;
icons?: typeof defaultLayoutIcons;
noScrubGesture?: boolean;
// Add other layout props as needed based on usage
};
}

View File

@ -58,6 +58,11 @@
background-color: transparent;
}
/* Override MDXEditor's bundled color:black on the contentEditable area */
.dark-editor [contenteditable] {
color: hsl(var(--foreground));
}
/* Toolbar dark mode adjustments if needed */
.dark-theme .mdx-toolbar {
background-color: hsl(var(--card));

View File

@ -1,16 +1,14 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
import { NetworkFirst } from 'workbox-strategies';
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { ExpirationPlugin } from 'workbox-expiration'
import { set } from 'idb-keyval'
const SW_VERSION = '1.0.5-debug';
const SW_VERSION = '2.0.0';
console.log(`[SW] Initializing Version: ${SW_VERSION}`);
declare let self: ServiceWorkerGlobalScope
self.addEventListener('message', (event) => {
@ -19,13 +17,31 @@ self.addEventListener('message', (event) => {
}
})
// self.__WB_MANIFEST is default injection point
precacheAndRoute(self.__WB_MANIFEST)
// Only precache the app shell — not all JS chunks
const manifest = self.__WB_MANIFEST;
const shellOnly = manifest.filter((entry) => {
const url = typeof entry === 'string' ? entry : entry.url;
return url === 'index.html'
|| url === 'favicon.ico'
|| url.endsWith('.png')
|| url.endsWith('.svg')
|| url === 'manifest.webmanifest';
});
precacheAndRoute(shellOnly);
// clean old assets
cleanupOutdatedCaches()
// Runtime cache: hashed static assets (JS/CSS) — cache-first since Vite hashes them
registerRoute(
({ url }) => url.pathname.startsWith('/assets/'),
new CacheFirst({
cacheName: 'assets-v1',
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// allow only fallback in dev: we don't want to cache everything
let allowlist: undefined | RegExp[]
@ -63,12 +79,12 @@ registerRoute(
'POST'
);
// Navigation handler: Prefer network to get server injection, fallback to index.html
// Navigation handler: Prefer network to get server injection, fallback to cached index.html
const navigationHandler = async (params: any) => {
try {
const strategy = new NetworkFirst({
cacheName: 'pages',
networkTimeoutSeconds: 3, // Fallback to cache if network is slow
networkTimeoutSeconds: 3,
plugins: [
{
cacheWillUpdate: async ({ response }) => {
@ -79,7 +95,10 @@ const navigationHandler = async (params: any) => {
});
return await strategy.handle(params);
} catch (error) {
return createHandlerBoundToURL('index.html')(params);
// Fallback: serve cached index.html from precache
const cache = await caches.match('index.html');
if (cache) return cache;
return new Response('Offline', { status: 503 });
}
};
@ -92,6 +111,16 @@ registerRoute(new NavigationRoute(
}
))
self.addEventListener('activate', () => {
clientsClaim();
// On activate, clean up old caches but keep current ones
self.addEventListener('activate', (event) => {
const currentCaches = new Set(['assets-v1', 'pages']);
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => !currentCaches.has(name) && !name.startsWith('workbox-precache'))
.map((name) => caches.delete(name))
)
)
);
});

View File

@ -168,6 +168,7 @@ export interface Author {
user_id: string
username: string
display_name: string
}
export type EventType = 'category' | 'post' | 'page' | 'system' | string;
@ -175,6 +176,7 @@ export interface AppEvent {
type: EventType;
kind: 'cache' | 'system' | 'chat' | 'other';
action: 'create' | 'update' | 'delete';
id?: string | null;
data: any;
timestamp: number;
}