flex container 1/2
This commit is contained in:
parent
a7f4fa2ac6
commit
56564ad1e5
175
packages/ui/docs/cache-ex.md
Normal file
175
packages/ui/docs/cache-ex.md
Normal 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
|
||||
@ -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)
|
||||
|
||||
642
packages/ui/docs/layoutcontainer-ex.md
Normal file
642
packages/ui/docs/layoutcontainer-ex.md
Normal 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 (1–12). Rendered via Tailwind `grid-cols-N` classes. |
|
||||
| **Widget placement** | Widgets have an `order` index. The CSS grid auto-flows them left-to-right, top-to-bottom. There is no "widget belongs to column X" field. |
|
||||
| **Column targeting** | `addWidgetToContainer()` accepts `targetColumn?: number` but only uses it to calculate an insertion _index_ — it is a positional hint derived from `index % columns`, not a persistent assignment. |
|
||||
| **Row concept** | None. Rows are an implicit side-effect of CSS grid wrapping. |
|
||||
| **Nesting** | Containers can nest up to 3 levels deep. Nested containers span `col-span-full`. |
|
||||
| **Container children** | `children: LayoutContainer[]` — nested containers always appear _after_ all widgets. |
|
||||
|
||||
### Rendering (`LayoutContainer.tsx`)
|
||||
|
||||
The component builds grid classes via `getGridClasses(columns)` → `grid-cols-1 md:grid-cols-N`. All widgets and children are rendered inside one `<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.
|
||||
@ -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"
|
||||
|
||||
16
packages/ui/shared/package-lock.json
generated
16
packages/ui/shared/package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -16,6 +16,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.1.5",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
220
packages/ui/shared/src/config/config.d.ts
vendored
Normal file
220
packages/ui/shared/src/config/config.d.ts
vendored
Normal 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;
|
||||
}
|
||||
225
packages/ui/shared/src/config/config.schema.ts
Normal file
225
packages/ui/shared/src/config/config.schema.ts
Normal 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>;
|
||||
@ -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(),
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
86
packages/ui/src/components/Footer.tsx
Normal file
86
packages/ui/src/components/Footer.tsx
Normal 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;
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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') {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
import { AVAILABLE_MODELS, getModelString } from '@/lib/image-router';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} -> {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} -> {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 >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
246
packages/ui/src/components/widgets/HomeWidget.tsx
Normal file
246
packages/ui/src/components/widgets/HomeWidget.tsx
Normal 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;
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(',')}`;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
146
packages/ui/src/i18n/zh.json
Normal file
146
packages/ui/src/i18n/zh.json
Normal 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?": "您有未保存的更改。您确定要放弃这些更改并退出吗?"
|
||||
}
|
||||
@ -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[] => {
|
||||
|
||||
@ -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']
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
61
packages/ui/src/modules/i18n/CategoryTranslationDialog.tsx
Normal file
61
packages/ui/src/modules/i18n/CategoryTranslationDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
473
packages/ui/src/modules/i18n/PageTranslationDialog.tsx
Normal file
473
packages/ui/src/modules/i18n/PageTranslationDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
340
packages/ui/src/modules/i18n/TranslationsGrid.tsx
Normal file
340
packages/ui/src/modules/i18n/TranslationsGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
93
packages/ui/src/modules/i18n/TypeTranslationDialog.tsx
Normal file
93
packages/ui/src/modules/i18n/TypeTranslationDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
496
packages/ui/src/modules/i18n/WidgetTranslationPanel.tsx
Normal file
496
packages/ui/src/modules/i18n/WidgetTranslationPanel.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@ -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();
|
||||
};
|
||||
|
||||
379
packages/ui/src/modules/i18n/useWidgetTranslation.ts
Normal file
379
packages/ui/src/modules/i18n/useWidgetTranslation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 || ''}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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."));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}` : ''}`;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
) : (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 */}
|
||||
|
||||
101
packages/ui/src/pages/UpdatePassword.tsx
Normal file
101
packages/ui/src/pages/UpdatePassword.tsx
Normal 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;
|
||||
@ -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
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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))
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user