This commit is contained in:
lovebird 2026-04-10 14:37:54 +02:00
parent 378b355693
commit 1127de34d4
9 changed files with 583 additions and 91 deletions

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

View 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)

View File

@ -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>} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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