ui
This commit is contained in:
parent
378b355693
commit
1127de34d4
192
packages/ui/docs/migrate-rls.md
Normal file
192
packages/ui/docs/migrate-rls.md
Normal file
@ -0,0 +1,192 @@
|
||||
# Backup from Supabase → Restore to plain Postgres
|
||||
|
||||
> **Status**: Active — in-flight migration from Supabase to plain Postgres + Zitadel
|
||||
> **Goal**: One correct, portable snapshot restorable on any plain Postgres instance with RLS intact.
|
||||
|
||||
---
|
||||
|
||||
## Why a raw dump doesn't work on plain Postgres
|
||||
|
||||
A `pg_dump` of a Supabase project contains things that don't exist on vanilla Postgres:
|
||||
|
||||
| Problem | Where it appears | Effect |
|
||||
|:---|:---|:---|
|
||||
| `\restrict` / `\unrestrict` tokens | Line 1 and last line of every dump | `psql` errors on unknown meta-command |
|
||||
| `auth.users` table | FK constraints (`REFERENCES auth.users(id)`) | restore fails — schema `auth` doesn't exist |
|
||||
| `auth.uid()` function | RLS policies, trigger functions | policies fail to create |
|
||||
| `authenticated`, `anon`, `service_role` roles | `TO authenticated` in policies | role not found error |
|
||||
|
||||
All four are solved before the first `psql` command runs — no manual file editing required.
|
||||
|
||||
---
|
||||
|
||||
## How it works on plain Postgres
|
||||
|
||||
`cli-ts/schemas/auth_setup.sql` creates:
|
||||
|
||||
- `anon`, `authenticated`, `service_role` roles — so `TO authenticated` in policies parses cleanly
|
||||
- `auth.uid()` — a real function that reads `current_setting('app.current_user_id', true)::uuid`
|
||||
- `auth.role()`, `auth.jwt()` stubs for any policy that calls them
|
||||
|
||||
`cli-ts/schemas/auth_users.sql` creates `auth.users` with all 34 columns that Supabase's `pg_dump` emits in its `COPY` statement — no column-mismatch errors on restore.
|
||||
|
||||
`backup-site` strips `\restrict`/`\unrestrict` tokens automatically after every dump.
|
||||
|
||||
**Result:** the structure dump restores as-is, RLS policies compile and evaluate against the session variable the server sets at request start — no policy rewriting, no policy dropping.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Take the backup
|
||||
|
||||
```bash
|
||||
pm-cli-cms backup-site --type all --include-auth --target ./backups/service.polymech.info
|
||||
```
|
||||
|
||||
Produces three files in `backups/service.polymech.info/backups/db/`:
|
||||
|
||||
| File | Contents |
|
||||
|:---|:---|
|
||||
| `structure-YY_MM_DD.sql` | Full public schema (DDL + RLS + indexes). Tokens already stripped. |
|
||||
| `data-YY_MM_DD.sql` | All public data. `vfs_index` + `vfs_document_chunks` rows excluded (see below). |
|
||||
| `auth-users-YY_MM_DD.sql` | `auth.users` rows only (ids, emails, hashed passwords). Tokens already stripped. |
|
||||
|
||||
> The `26_04_10` backup in `backups/service.polymech.info/backups/db/` is the current clean baseline — tokens already stripped, ready to restore.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — One-time target setup (per Postgres instance)
|
||||
|
||||
Run these once on any new target. Both scripts are idempotent (`CREATE IF NOT EXISTS`).
|
||||
|
||||
```bash
|
||||
# Roles + auth.uid() + auth schema
|
||||
psql "$TARGET_DATABASE_URL" -f cli-ts/schemas/auth_setup.sql
|
||||
|
||||
# auth.users table (34-column definition matching pg_dump output)
|
||||
psql "$TARGET_DATABASE_URL" -f cli-ts/schemas/auth_users.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Restore
|
||||
|
||||
```bash
|
||||
# 1. Seed auth.users rows (must come before public schema — FK targets must exist)
|
||||
psql "$TARGET_DATABASE_URL" \
|
||||
-f backups/service.polymech.info/backups/db/auth-users-26_04_10.sql
|
||||
|
||||
# 2. Restore public schema (RLS policies restore as-is, auth.uid() already exists)
|
||||
pm-cli-cms db-import \
|
||||
--schema backups/service.polymech.info/backups/db/structure-26_04_10.sql \
|
||||
--env ./server/.env.production
|
||||
|
||||
# 3. Restore public data
|
||||
pm-cli-cms db-import \
|
||||
--data backups/service.polymech.info/backups/db/data-26_04_10.sql \
|
||||
--env ./server/.env.production
|
||||
```
|
||||
|
||||
**Full wipe + restore** — `--clear` drops/recreates `public` only; the `auth` schema is untouched:
|
||||
|
||||
```bash
|
||||
psql "$TARGET_DATABASE_URL" -f backups/service.polymech.info/backups/db/auth-users-26_04_10.sql
|
||||
|
||||
pm-cli-cms db-import \
|
||||
--schema backups/service.polymech.info/backups/db/structure-26_04_10.sql \
|
||||
--data backups/service.polymech.info/backups/db/data-26_04_10.sql \
|
||||
--clear \
|
||||
--env ./server/.env.production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Verify
|
||||
|
||||
```sql
|
||||
-- Public tables present
|
||||
SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY 1;
|
||||
|
||||
-- vfs_index structure present, data empty (intentional)
|
||||
\d public.vfs_index
|
||||
\d public.vfs_document_chunks
|
||||
|
||||
-- auth.uid() resolves (returns NULL — no session var set yet, expected)
|
||||
SELECT auth.uid();
|
||||
|
||||
-- RLS policies present
|
||||
SELECT tablename, policyname FROM pg_policies WHERE schemaname = 'public' ORDER BY 1, 2;
|
||||
|
||||
-- auth.users rows seeded
|
||||
SELECT id, email, created_at FROM auth.users ORDER BY created_at;
|
||||
|
||||
-- Row counts
|
||||
SELECT
|
||||
(SELECT count(*) FROM public.posts) AS posts,
|
||||
(SELECT count(*) FROM public.pictures) AS pictures,
|
||||
(SELECT count(*) FROM public.pages) AS pages,
|
||||
(SELECT count(*) FROM public.places) AS places;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Tables excluded from data dumps by default
|
||||
|
||||
| Table | Reason |
|
||||
|:---|:---|
|
||||
| `vfs_index` | Can be huge; re-populated by the VFS indexer |
|
||||
| `vfs_document_chunks` | Embedding vectors; re-generated by the vectorisation pipeline |
|
||||
|
||||
To include them: `--exclude-data-tables ""`
|
||||
|
||||
### How RLS works without Supabase
|
||||
|
||||
| Layer | Supabase | Plain Postgres (this setup) |
|
||||
|:---|:---|:---|
|
||||
| `auth.uid()` | GoTrue JWT claim injected by PostgREST | `current_setting('app.current_user_id', true)::uuid` |
|
||||
| Set by | PostgREST on every request | Server: `SET LOCAL app.current_user_id = $1` at request start (application pool) |
|
||||
| Service pool | `postgres` superuser → bypasses RLS | Same — bypasses RLS |
|
||||
| Policy syntax | Unchanged | Unchanged — no rewriting needed |
|
||||
|
||||
### Stripping `\restrict` tokens from existing dumps
|
||||
|
||||
Going forward `backup-site` handles this automatically. For any dump already on disk:
|
||||
|
||||
```bash
|
||||
# bash
|
||||
sed -i '/^\\(un\)\?restrict /d' file.sql
|
||||
|
||||
# PowerShell
|
||||
(Get-Content file.sql) -replace '(?m)^\\(un)?restrict\s+\S+\s*$','' | Set-Content file.sql
|
||||
```
|
||||
|
||||
### Duplicating this baseline to another instance
|
||||
|
||||
```bash
|
||||
# One-time setup on staging
|
||||
psql "$STAGING_DATABASE_URL" -f cli-ts/schemas/auth_setup.sql
|
||||
psql "$STAGING_DATABASE_URL" -f cli-ts/schemas/auth_users.sql
|
||||
|
||||
# Seed users + restore
|
||||
psql "$STAGING_DATABASE_URL" -f backups/service.polymech.info/backups/db/auth-users-26_04_10.sql
|
||||
|
||||
pm-cli-cms db-import \
|
||||
--schema backups/service.polymech.info/backups/db/structure-26_04_10.sql \
|
||||
--data backups/service.polymech.info/backups/db/data-26_04_10.sql \
|
||||
--clear \
|
||||
--env ./server/.env.staging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future: when Zitadel is the only auth provider
|
||||
|
||||
`server/src/commons/zitadel.ts` + `postgres.ts` are already live. `resolveAppUserId()` maps Zitadel `sub` → `profiles.user_id` UUID with `auth.users` as an optional fallback (guarded by `hasAuthUsersTable()`).
|
||||
|
||||
When ready to fully drop the Supabase user store:
|
||||
|
||||
1. Ensure all active users have `profiles.zitadel_sub` set.
|
||||
2. `auth.users` is now a plain Postgres table — keep it as a thin identity table or migrate emails into `profiles`.
|
||||
3. Update `REFERENCES auth.users(id)` FKs in migrations to point to `public.profiles(user_id)` if dropping it.
|
||||
4. `auth.uid()` already reads from `app.current_user_id` — no policy changes needed.
|
||||
294
packages/ui/docs/supabase/rls-leaving-supabase.md
Normal file
294
packages/ui/docs/supabase/rls-leaving-supabase.md
Normal file
@ -0,0 +1,294 @@
|
||||
# RLS (Leaving Supabase)
|
||||
|
||||
## Overview
|
||||
|
||||
Chapter in the **Leaving Supabase** series: Row-Level Security moves from Supabase's GoTrue-coupled model (`auth.uid()` + PostgREST) to **plain Postgres RLS** backed by a session variable, while keeping every existing policy intact — no rewrites, no policy drops.
|
||||
|
||||
**Contents**
|
||||
|
||||
1. **§1 — What Supabase RLS actually is** — The GoTrue coupling, what breaks when you leave.
|
||||
2. **§2 — The five portability problems** — Tokens, `auth.users`, `auth.uid()`, missing roles, BYPASSRLS.
|
||||
3. **§3 — The workarounds** — How each problem is solved without modifying policies.
|
||||
4. **§4 — How RLS evaluates in the new architecture** — Session variable, pool separation.
|
||||
5. **§5 — The `auth` schema on plain Postgres** — Minimal `auth.users`, no GoTrue.
|
||||
6. **References** — Related chapters.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Supabase RLS actually is
|
||||
|
||||
Supabase RLS is standard **Postgres Row-Level Security** — nothing proprietary — but it is wired through two Supabase-specific pieces:
|
||||
|
||||
### 1.1 PostgREST sets the JWT context
|
||||
|
||||
When a browser calls the Supabase API, PostgREST:
|
||||
1. Validates the GoTrue JWT
|
||||
2. Calls `SET LOCAL request.jwt.claims = '<payload>'` on the Postgres connection
|
||||
3. Sets `ROLE authenticated` (or `anon`) for that transaction
|
||||
|
||||
Supabase then provides `auth.uid()` as a thin wrapper around that claim:
|
||||
|
||||
```sql
|
||||
-- Supabase's actual auth.uid() implementation (GoTrue / PostgREST)
|
||||
CREATE OR REPLACE FUNCTION auth.uid()
|
||||
RETURNS uuid AS $$
|
||||
SELECT COALESCE(
|
||||
current_setting('request.jwt.claims', true)::jsonb ->> 'sub',
|
||||
(current_setting('request.jwt.claims', true)::jsonb ->> 'id')
|
||||
)::uuid
|
||||
$$ LANGUAGE sql STABLE;
|
||||
```
|
||||
|
||||
### 1.2 All policies reference that function
|
||||
|
||||
```sql
|
||||
-- Typical Supabase RLS policy
|
||||
CREATE POLICY "owner can read" ON public.posts
|
||||
FOR SELECT TO authenticated
|
||||
USING (user_id = auth.uid());
|
||||
```
|
||||
|
||||
### 1.3 What breaks when you leave
|
||||
|
||||
When you connect to Postgres directly (no PostgREST, no GoTrue JWT pipe):
|
||||
|
||||
| What fails | Why |
|
||||
|:---|:---|
|
||||
| `auth.uid()` | Function doesn't exist outside Supabase |
|
||||
| `TO authenticated` | Role doesn't exist on plain Postgres |
|
||||
| `REFERENCES auth.users(id)` | Schema `auth` doesn't exist |
|
||||
| `\restrict` / `\unrestrict` in dumps | Supabase-custom psql tokens, unknown to standard `psql` |
|
||||
| Server-side admin queries | Service user lacks `BYPASSRLS` → RLS blocks its own queries |
|
||||
|
||||
---
|
||||
|
||||
## 2. The five portability problems
|
||||
|
||||
### Problem 1 — `\restrict` tokens in pg_dump output
|
||||
|
||||
Supabase injects a security token on line 1 and the last line of every `pg_dump` file:
|
||||
|
||||
```
|
||||
\restrict CTr7scG9HDQwlW5lApN1nPGSJZnEc9H...
|
||||
...
|
||||
\unrestrict CTr7scG9HDQwlW5lApN1nPGSJZnEc9H...
|
||||
```
|
||||
|
||||
Standard `psql` doesn't recognise these meta-commands and will error.
|
||||
|
||||
**Fix:** strip them before restoring:
|
||||
|
||||
```bash
|
||||
# bash
|
||||
sed -i '/^\\(un\)\?restrict /d' dump.sql
|
||||
|
||||
# PowerShell
|
||||
(Get-Content dump.sql) -replace '(?m)^\\(un)?restrict\s+\S+\s*$','' | Set-Content dump.sql
|
||||
```
|
||||
|
||||
This is handled automatically by the backup tooling on every new dump.
|
||||
|
||||
---
|
||||
|
||||
### Problem 2 — `auth.users` doesn't exist
|
||||
|
||||
Every FK that references `auth.users(id)` fails during structure restore:
|
||||
|
||||
```
|
||||
ERROR: relation "auth.users" does not exist
|
||||
```
|
||||
|
||||
**Fix:** create a minimal `auth.users` table. The table needs all 34 columns that appear in Supabase's `pg_dump COPY` statement — all nullable or defaulted, no GoTrue functions needed.
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
email text UNIQUE,
|
||||
encrypted_password text,
|
||||
-- ... 31 more GoTrue bookkeeping columns (all nullable / defaulted)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
The full definition lives in `cli-ts/schemas/auth_users.sql`.
|
||||
|
||||
> **Restore order:** `auth` schema + table must be created AFTER `db-import --clear` creates the database, but the `auth` schema sits outside `public` so `--clear` never touches it on subsequent restores.
|
||||
|
||||
---
|
||||
|
||||
### Problem 3 — `auth.uid()` doesn't exist
|
||||
|
||||
Every policy that calls `auth.uid()` fails to create:
|
||||
|
||||
```
|
||||
ERROR: function auth.uid() does not exist
|
||||
```
|
||||
|
||||
**Fix:** create `auth.uid()` as a real Postgres function that reads a session variable set by the server:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION auth.uid()
|
||||
RETURNS uuid
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT NULLIF(current_setting('app.current_user_id', true), '')::uuid
|
||||
$$;
|
||||
```
|
||||
|
||||
The server sets this variable at the start of each request on the application pool connection:
|
||||
|
||||
```typescript
|
||||
await db.query('SET LOCAL app.current_user_id = $1', [userId]);
|
||||
```
|
||||
|
||||
`SET LOCAL` scopes the variable to the current transaction — automatically cleared at transaction end, no cross-request leakage.
|
||||
|
||||
---
|
||||
|
||||
### Problem 4 — `authenticated` / `anon` roles don't exist
|
||||
|
||||
Policies with `TO authenticated` or `TO anon` fail with:
|
||||
|
||||
```
|
||||
ERROR: role "authenticated" does not exist
|
||||
```
|
||||
|
||||
**Fix:** create the roles. They never need to log in — they only exist so policy DDL parses without error:
|
||||
|
||||
```sql
|
||||
CREATE ROLE anon NOLOGIN NOINHERIT;
|
||||
CREATE ROLE authenticated NOLOGIN NOINHERIT;
|
||||
CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Problem 5 — service user can't read its own tables (BYPASSRLS)
|
||||
|
||||
This is the most subtle problem. After restore, server-side queries like `isAdmin` or role lookups return empty results even though the data is there:
|
||||
|
||||
```
|
||||
GET /api/me/identity → { "roles": [] } -- user_roles has rows, but RLS blocks them
|
||||
```
|
||||
|
||||
**Why:** The server's database user (`service.polymech`) is subject to RLS. Policies on `user_roles` check `auth.uid()`. For **server-side** queries that don't represent an end-user request — `isAdmin()`, role resolution, pg-boss startup — there is no session variable set, so `auth.uid()` returns `NULL`. Every RLS policy that checks `auth.uid()` then blocks all rows.
|
||||
|
||||
On Supabase this never occurred because the server connected as `postgres` (superuser) which bypasses RLS unconditionally.
|
||||
|
||||
**Fix:** grant `BYPASSRLS` to the service user:
|
||||
|
||||
```sql
|
||||
ALTER ROLE "service.polymech" WITH BYPASSRLS;
|
||||
```
|
||||
|
||||
This is included automatically in the provisioning step (`db-import --create-user`). For an existing role:
|
||||
|
||||
```bash
|
||||
psql "postgresql://root:...@host:5432/postgres" \
|
||||
-c 'ALTER ROLE "service.polymech" WITH BYPASSRLS;'
|
||||
```
|
||||
|
||||
`BYPASSRLS` does not make the user a superuser — it only skips RLS enforcement for that role. The server still enforces authorisation at the application layer via `@polymech/acl` and middleware checks.
|
||||
|
||||
---
|
||||
|
||||
## 3. The workarounds in sequence
|
||||
|
||||
```
|
||||
1. db-import --clear → creates DB if missing, wipes public schema, restores structure + data
|
||||
2. DROP SCHEMA IF EXISTS auth CASCADE
|
||||
3. CREATE SCHEMA auth
|
||||
4. CREATE ROLE anon / authenticated / service_role
|
||||
5. CREATE FUNCTION auth.uid() → reads app.current_user_id
|
||||
6. CREATE FUNCTION auth.role() → stub
|
||||
7. CREATE FUNCTION auth.jwt() → stub (returns '{}')
|
||||
8. CREATE TABLE auth.users → 34-column definition matching pg_dump COPY output
|
||||
9. COPY auth.users rows → real user identities from the Supabase backup
|
||||
10. ALTER ROLE "service.polymech" WITH BYPASSRLS → (handled by --create-user in db-import)
|
||||
```
|
||||
|
||||
After step 9, every RLS policy is live and evaluates correctly. After step 10, server-side queries work without a session variable.
|
||||
|
||||
---
|
||||
|
||||
## 4. How RLS evaluates in the new architecture
|
||||
|
||||
### Pool separation
|
||||
|
||||
The server maintains two Postgres connection pools:
|
||||
|
||||
| Pool | Connects as | RLS | Used for |
|
||||
|:---|:---|:---|:---|
|
||||
| **service pool** | `service.<site>` + `BYPASSRLS` | **Bypassed** | All server-side queries: `isAdmin`, role lookups, data reads/writes |
|
||||
| **pg-boss** | `root` via `DATABASE_URL_SERVICE` | **Bypassed** (superuser) | Schema provisioning, job queue DDL |
|
||||
|
||||
`BYPASSRLS` on `service.<site>` gives the server the same effective privilege as Supabase's `postgres` superuser connection — without needing a superuser account for routine queries. RLS remains enforced for any direct external connection that doesn't have `BYPASSRLS`.
|
||||
|
||||
### Request lifecycle
|
||||
|
||||
```
|
||||
Incoming request
|
||||
→ verify Zitadel JWT (zitadel.ts)
|
||||
→ resolveAppUserId(sub, email) → app UUID
|
||||
→ SET LOCAL app.current_user_id = '<uuid>'
|
||||
→ query executes
|
||||
→ auth.uid() returns that UUID
|
||||
→ RLS policy evaluates → row visible / blocked
|
||||
→ transaction ends → app.current_user_id cleared automatically
|
||||
```
|
||||
|
||||
For **server-internal** queries (no end-user context): `BYPASSRLS` means the policy is never evaluated — the server reads/writes freely and enforces access control at the application layer.
|
||||
|
||||
### Why this is equivalent to Supabase RLS
|
||||
|
||||
| Supabase | Plain Postgres |
|
||||
|:---|:---|
|
||||
| PostgREST validates GoTrue JWT | Server validates Zitadel JWT |
|
||||
| `SET LOCAL request.jwt.claims = '<payload>'` | `SET LOCAL app.current_user_id = '<uuid>'` |
|
||||
| `auth.uid()` reads JWT `sub` claim | `auth.uid()` reads session variable |
|
||||
| `postgres` superuser bypasses RLS for server | `service.polymech` + `BYPASSRLS` bypasses RLS for server |
|
||||
| `ROLE authenticated` set by PostgREST | Application pool user is always "authenticated" by definition |
|
||||
|
||||
---
|
||||
|
||||
## 5. The `auth` schema on plain Postgres
|
||||
|
||||
After running the compatibility setup, the `auth` schema contains:
|
||||
|
||||
```
|
||||
auth
|
||||
├── users — plain table, 34 columns, no GoTrue triggers/functions
|
||||
├── uid() — reads current_setting('app.current_user_id', true)::uuid
|
||||
├── role() — stub, returns current_setting('app.current_role', true)
|
||||
└── jwt() — stub, returns '{}'::jsonb
|
||||
```
|
||||
|
||||
**`auth.users` is now just a table.** No GoTrue functions, no triggers, no event hooks:
|
||||
- Queried directly: `SELECT id, email FROM auth.users`
|
||||
- Written directly: `INSERT INTO auth.users (id, email, ...) VALUES (...)`
|
||||
- Backed up with standard `pg_dump`
|
||||
- Migrated to `public.profiles` or dropped when no longer needed
|
||||
|
||||
### Transition path
|
||||
|
||||
`resolveAppUserId()` in `postgres.ts` chains through three identity paths:
|
||||
|
||||
1. `profiles.zitadel_sub` — Zitadel numeric `sub` (preferred, no `auth.users` needed)
|
||||
2. `auth.users.email` join — legacy Supabase email match (guarded by `hasAuthUsersTable()`)
|
||||
3. `profiles.username` — final fallback
|
||||
|
||||
As users accumulate `profiles.zitadel_sub` entries, dependency on `auth.users` fades naturally. When all active users have a `zitadel_sub`, `auth.users` can be dropped and its FKs replaced with `public.profiles(user_id)`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- `docs/supabase/auth-zitadel.md` — Identity migration: Supabase Auth → Zitadel OIDC
|
||||
- `docs/deploy/ditch-supabase.md` — Full migration plan, phases, risk assessment
|
||||
- `docs/migrate-rls.md` — Backup / restore operational runbook
|
||||
- `server/src/commons/zitadel.ts` — JWT verification, `resolveAppUserId`
|
||||
- `server/src/commons/postgres.ts` — Pool setup, `hasAuthUsersTable`, `isAdmin`
|
||||
- `cli-ts/schemas/auth_setup.sql` — Roles + `auth.uid()` setup script
|
||||
- `cli-ts/schemas/auth_users.sql` — Minimal `auth.users` table definition (34 columns)
|
||||
@ -50,7 +50,6 @@ let VideoPlayerPlaygroundIntern: any;
|
||||
let PlaygroundImages: any;
|
||||
let PlaygroundImageEditor: any;
|
||||
let VideoGenPlayground: any;
|
||||
let PlaygroundCanvas: any;
|
||||
let TypesPlayground: any;
|
||||
let VariablePlayground: any;
|
||||
let I18nPlayground: any;
|
||||
@ -200,7 +199,6 @@ const AppWrapper = () => {
|
||||
<Route path="/playground/images" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImages /></React.Suspense>} />
|
||||
<Route path="/playground/image-editor" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundImageEditor /></React.Suspense>} />
|
||||
<Route path="/playground/video-generator" element={<React.Suspense fallback={<div>Loading...</div>}><VideoGenPlayground /></React.Suspense>} />
|
||||
<Route path="/playground/canvas" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundCanvas /></React.Suspense>} />
|
||||
<Route path="/variables-editor" element={<React.Suspense fallback={<div>Loading...</div>}><VariablePlayground /></React.Suspense>} />
|
||||
<Route path="/playground/i18n" element={<React.Suspense fallback={<div>Loading...</div>}><I18nPlayground /></React.Suspense>} />
|
||||
<Route path="/playground/chat" element={<React.Suspense fallback={<div>Loading...</div>}><PlaygroundChat /></React.Suspense>} />
|
||||
|
||||
@ -21,13 +21,22 @@ const CreationWizardPopup = lazy(() => import('./CreationWizardPopup').then(m =>
|
||||
|
||||
const TopNavigation = () => {
|
||||
const { user, signOut, roles } = useAuth();
|
||||
console.log('user', user);
|
||||
const { fetchProfile, profiles } = useProfiles();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentLang = getCurrentLang();
|
||||
|
||||
// Keep nav search in sync with URL when on /search
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/search') {
|
||||
const params = new URLSearchParams(location.search);
|
||||
setSearchQuery(params.get('q') || '');
|
||||
} else {
|
||||
setSearchQuery('');
|
||||
}
|
||||
}, [location.pathname, location.search]);
|
||||
const { creationWizardOpen, setCreationWizardOpen, wizardInitialImage, creationWizardMode } = useWizardContext();
|
||||
|
||||
// Lazy-load ecommerce cart store to keep the heavy ecommerce bundle out of the initial load
|
||||
|
||||
@ -195,65 +195,83 @@ const HomeWidget: React.FC<HomeWidgetProps> = ({
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<ToggleGroup type="single" value={sortBy} onValueChange={handleSortChange}>
|
||||
<ToggleGroupItem value="latest" aria-label="Latest Posts" size={size}>
|
||||
<Clock className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Latest</T></span>
|
||||
<Clock className="h-4 w-4 mr-1.5" />
|
||||
<T>Latest</T>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="top" aria-label="Top Posts" size={size}>
|
||||
<TrendingUp className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Top</T></span>
|
||||
<TrendingUp className="h-4 w-4 mr-1.5" />
|
||||
<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 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Categories</T></span>
|
||||
<FolderTree className="h-4 w-4 mr-1.5" />
|
||||
<T>Categories</T>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup type="single" value={contentType || 'all'} onValueChange={(v) => {
|
||||
if (!v || v === 'all') handleContentTypeChange(undefined);
|
||||
else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places');
|
||||
}}>
|
||||
<ToggleGroupItem value="all" aria-label="All content" size={size}>
|
||||
<span className="hidden md:inline"><T>All</T></span>
|
||||
<span className="md:hidden text-xs">All</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="posts" aria-label="Posts only" size={size}>
|
||||
<ImageIcon className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Posts</T></span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="pages" aria-label="Pages only" size={size}>
|
||||
<FileText className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Pages</T></span>
|
||||
</ToggleGroupItem>
|
||||
{searchQuery && (
|
||||
<ToggleGroupItem value="places" aria-label="Places only" size={size}>
|
||||
<MapPin className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Places</T></span>
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<ToggleGroupItem value="pictures" aria-label="Pictures only" size={size}>
|
||||
<Camera className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Pictures</T></span>
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
<ToggleGroupItem value="files" aria-label="Files only" size={size}>
|
||||
<FolderTree className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Files</T></span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{isOwnProfile && (
|
||||
<ToggleGroup type="single" value={visibilityFilter || ''} onValueChange={handleVisibilityFilterChange}>
|
||||
<ToggleGroupItem value="invisible" aria-label="Show invisible only" size={size}>
|
||||
<EyeOff className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Invisible</T></span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="private" aria-label="Show private only" size={size}>
|
||||
<Lock className="h-4 w-4 md:mr-2" />
|
||||
<span className="hidden md:inline"><T>Private</T></span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
{/* Content type + visibility — collapsed into a compact dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 px-2.5 relative">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
{contentType ? (
|
||||
<span className="text-xs font-medium capitalize"><T>{contentType}</T></span>
|
||||
) : (
|
||||
<span className="text-xs font-medium"><T>All</T></span>
|
||||
)}
|
||||
{(contentType || visibilityFilter) && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuLabel className="text-xs"><T>Content type</T></DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={contentType || 'all'} onValueChange={(v) => {
|
||||
if (v === 'all') handleContentTypeChange(undefined);
|
||||
else handleContentTypeChange(v as 'posts' | 'pages' | 'pictures' | 'files' | 'places');
|
||||
}}>
|
||||
<DropdownMenuRadioItem value="all">
|
||||
<Layers className="h-4 w-4 mr-2" /><T>All</T>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="posts">
|
||||
<ImageIcon className="h-4 w-4 mr-2" /><T>Posts</T>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="pages">
|
||||
<FileText className="h-4 w-4 mr-2" /><T>Pages</T>
|
||||
</DropdownMenuRadioItem>
|
||||
{searchQuery && (
|
||||
<DropdownMenuRadioItem value="places">
|
||||
<MapPin className="h-4 w-4 mr-2" /><T>Places</T>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{isOwnProfile && (
|
||||
<DropdownMenuRadioItem value="pictures">
|
||||
<Camera className="h-4 w-4 mr-2" /><T>Pictures</T>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
<DropdownMenuRadioItem value="files">
|
||||
<FolderTree className="h-4 w-4 mr-2" /><T>Files</T>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs"><T>Visibility</T></DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={visibilityFilter || ''} onValueChange={handleVisibilityFilterChange}>
|
||||
<DropdownMenuRadioItem value="">
|
||||
<Layers className="h-4 w-4 mr-2" /><T>All</T>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="invisible">
|
||||
<EyeOff className="h-4 w-4 mr-2" /><T>Invisible</T>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="private">
|
||||
<Lock className="h-4 w-4 mr-2" /><T>Private</T>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -186,16 +186,8 @@ const NewPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pt-14">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
<T>Back</T>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@ -194,14 +194,6 @@ const PageCard: React.FC<PageCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile title below thumbnail */}
|
||||
<div className="md:hidden px-2 py-1.5 bg-muted/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate flex-1 mr-2">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar below image (preset-driven) */}
|
||||
{(preset?.showTitle || preset?.showDescription) && (title || description) && (
|
||||
<div className="px-2.5 py-2 border-t bg-muted/40">
|
||||
|
||||
@ -234,24 +234,21 @@ export const MobileGroupItem: React.FC<MobileGroupItemProps> = ({
|
||||
|
||||
{(item.likes_count || 0) > 0 && <div className="font-semibold text-sm px-2 mb-2">{item.likes_count} likes</div>}
|
||||
|
||||
{(item.title || item.description) && (
|
||||
{item.description && (
|
||||
<div className="px-2 pb-2 space-y-1">
|
||||
{item.title && !isLikelyFilename(item.title) && <div className="font-semibold text-sm">{item.title}</div>}
|
||||
{item.description && (
|
||||
<div className="text-sm text-foreground/90">
|
||||
<div className={expandedDescriptions[item.id] ? "" : "line-clamp-2"}>
|
||||
<MarkdownRenderer content={item.description} className="prose-sm dark:prose-invert" />
|
||||
</div>
|
||||
{item.description.length > 100 && !expandedDescriptions[item.id] && (
|
||||
<button
|
||||
onClick={() => setExpandedDescriptions(prev => ({ ...prev, [item.id]: true }))}
|
||||
className="text-muted-foreground text-xs mt-1 font-medium hover:text-foreground"
|
||||
>
|
||||
more
|
||||
</button>
|
||||
)}
|
||||
<div className="text-sm text-foreground/90">
|
||||
<div className={expandedDescriptions[item.id] ? "" : "line-clamp-2"}>
|
||||
<MarkdownRenderer content={item.description} className="prose-sm dark:prose-invert" />
|
||||
</div>
|
||||
)}
|
||||
{item.description.length > 100 && !expandedDescriptions[item.id] && (
|
||||
<button
|
||||
onClick={() => setExpandedDescriptions(prev => ({ ...prev, [item.id]: true }))}
|
||||
className="text-muted-foreground text-xs mt-1 font-medium hover:text-foreground"
|
||||
>
|
||||
more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground pt-1">
|
||||
{formatDate(item.created_at)}
|
||||
</div>
|
||||
|
||||
@ -60,9 +60,9 @@ const SearchResults = () => {
|
||||
|
||||
if (!query.trim()) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background pt-14">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8 max-w-6xl">
|
||||
<div className="max-w-md mx-auto mt-8 mb-8">
|
||||
<div className="max-w-md mx-auto mt-8 mb-8 sm:hidden">
|
||||
<form onSubmit={handleSearchSubmit} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@ -87,8 +87,8 @@ const SearchResults = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pt-14">
|
||||
<div className="container mx-auto px-4 max-w-6xl pb-4">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 max-w-6xl pb-4 sm:hidden">
|
||||
<form onSubmit={handleSearchSubmit} className="relative max-w-xl mx-auto">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
|
||||
Loading…
Reference in New Issue
Block a user