diff --git a/apps/uload/CLAUDE.md b/apps/uload/CLAUDE.md index 578aecd42..c3384f87e 100644 --- a/apps/uload/CLAUDE.md +++ b/apps/uload/CLAUDE.md @@ -1,132 +1,128 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -uLoad is a URL shortener and link management platform built with SvelteKit and PocketBase. +# uLoad — URL Shortener & Link Management **Live:** https://ulo.ad +## Architecture + +uLoad uses a **local-first** architecture with a lightweight Hono/Bun server for redirects and analytics. + +``` +Browser → IndexedDB (Links, Tags, Folders) + ↕ sync + mana-sync → PostgreSQL + +Browser → /r/:code → Hono Server → PostgreSQL (redirect + click tracking) +``` + ## Project Structure ``` -uload/ +apps/uload/ ├── apps/ -│ └── web/ # SvelteKit web application -│ ├── src/ # Source code -│ │ ├── routes/ # SvelteKit pages -│ │ └── lib/ # Components, services, utilities -│ ├── static/ # Static assets -│ └── e2e/ # End-to-end tests -├── backend/ # PocketBase configuration -│ ├── pb_migrations/ # Database migrations -│ └── pb_schema.json # Schema definition -├── docs/ # Documentation -├── scripts/ # Utility scripts -└── CLAUDE.md +│ ├── web/ # SvelteKit web app (local-first) +│ ├── server/ # Hono/Bun redirect & analytics server +│ └── landing/ # Astro marketing page +├── packages/ +│ └── uload-database/ # Shared Drizzle schema +└── package.json ``` +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Web** | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 | +| **Server** | Hono + Bun | +| **Data** | Local-first (Dexie.js + mana-sync) | +| **Database** | PostgreSQL via Drizzle ORM | +| **Auth** | mana-core-auth (Better Auth + EdDSA JWT) | +| **Landing** | Astro 5 | +| **PWA** | @vite-pwa/sveltekit | +| **i18n** | svelte-i18n (DE/EN) | + ## Commands -All commands should be run from `uload/apps/web/`: - -### Development - ```bash -pnpm run dev # Start development server (http://localhost:5173) -pnpm run preview # Preview production build locally +# Development +pnpm dev:uload:web # SvelteKit dev server +pnpm dev:uload:server # Hono/Bun server (port 3070) +pnpm dev:uload:landing # Landing page +pnpm dev:uload:local # Web + Sync + Server (no auth) +pnpm dev:uload:full # Everything incl. auth + +# Build & Deploy +pnpm --filter @uload/web build +pnpm --filter @uload/landing build +pnpm deploy:landing:uload # Deploy landing to Cloudflare Pages + +# Type Check +pnpm --filter @uload/web check +pnpm --filter @uload/server type-check +pnpm --filter @manacore/uload-database type-check ``` -### Build & Deploy +## Ports + +| Service | Dev Port | Prod Port | +|---------|----------|-----------| +| Web | 5173 | 5029 | +| Server | 3070 | 3041 | +| Landing | 4321 | Cloudflare Pages | + +## Hono Server Routes + +| Route | Auth | Description | +|-------|------|-------------| +| `GET /health` | No | Health check | +| `GET /r/:code` | No | Redirect + click tracking | +| `GET /public/u/:username` | No | Public user profile + links | +| `GET /api/v1/analytics/:linkId` | JWT | Click stats | +| `GET /api/v1/analytics/:linkId/timeline` | JWT | Clicks over time | +| `GET /api/v1/analytics/:linkId/devices` | JWT | Device breakdown | +| `GET /api/v1/analytics/:linkId/referrers` | JWT | Top referrers | +| `GET /api/v1/analytics/:linkId/countries` | JWT | Country breakdown | +| `POST /api/v1/stripe/checkout` | JWT | Stripe session (stub) | +| `POST /api/v1/stripe/webhook` | No | Stripe webhook (stub) | +| `POST /api/v1/email/send-invitation` | JWT | Team invite (stub) | + +## Local-First Collections + +| Collection | Fields | +|-----------|--------| +| `links` | shortCode, originalUrl, title, isActive, clickCount, utmSource/Medium/Campaign, folderId | +| `tags` | name, slug, color, icon, isPublic, usageCount | +| `folders` | name, color, order | +| `linkTags` | linkId, tagId | + +## Web App Pages + +| Route | Description | +|-------|-------------| +| `/my/links` | Link management (CRUD, QR, UTM, bulk) | +| `/my/tags` | Tag management | +| `/my/analytics/[id]` | Per-link analytics dashboard | +| `/settings` | Account & data settings | +| `/pricing` | Subscription plans (static) | +| `/u/[username]` | Public user profile | +| `/login` | Login (shared-auth-ui) | +| `/register` | Register (shared-auth-ui) | + +## Docker ```bash -pnpm run build # Create production build +# Build +./scripts/mac-mini/build-app.sh uload-web +./scripts/mac-mini/build-app.sh uload-server + +# Services in docker-compose.macmini.yml: +# - uload-server (port 3041, Bun) +# - uload-web (port 5029, Node) ``` -### Code Quality - -```bash -pnpm run format # Auto-format code with Prettier -pnpm run lint # Run ESLint and Prettier checks -pnpm run check # Run Svelte type checking -``` - -### Testing - -```bash -pnpm run test # Run all tests (unit + e2e) -pnpm run test:unit # Run unit tests with Vitest -pnpm run test:e2e # Run end-to-end tests with Playwright -``` - -### Database - -```bash -pnpm run db:generate # Generate Drizzle migrations -pnpm run db:migrate # Run migrations -pnpm run db:push # Push schema changes -pnpm run db:studio # Open Drizzle Studio -``` - -## Technology Stack - -- **Framework**: SvelteKit v2.22 with Svelte 5.0 -- **Backend**: PocketBase (embedded SQLite) -- **Database**: PostgreSQL via Drizzle ORM + Redis for caching -- **Styling**: Tailwind CSS v4.0 -- **Testing**: Vitest + Playwright -- **Payments**: Stripe -- **Email**: Resend -- **Storage**: Cloudflare R2 - ## Key Patterns -### Svelte 5 Runes Mode - -- **NEVER use `$:` reactive statements** - use `$derived` instead -- **NEVER use `let` for reactive values** - use `$state` for reactive state -- **For side effects** - use `$effect` instead of `$:` statements - -```typescript -// ✅ CORRECT - Svelte 5 runes -let headerModule = $derived(card.config.modules?.find((m) => m.type === 'header')); -let count = $state(0); - -$effect(() => { - console.log('Count changed:', count); -}); -``` - -### PocketBase Usage - -In server-side code (`+page.server.ts`, `+server.ts`): - -- **ALWAYS use `locals.pb`** from the request context -- The imported `pb` is for client-side only - -```typescript -// Server-side -export const load: PageServerLoad = async ({ locals }) => { - const items = await locals.pb.collection('items').getList(); -}; - -// Client-side -import { pb } from '$lib/pocketbase'; -``` - -## Environment Configuration - -Copy `.env.example` to `.env` and configure: - -- `DATABASE_URL` - PostgreSQL connection string -- `R2_*` - Cloudflare R2 storage credentials -- `RESEND_API_KEY` - Email service -- `STRIPE_*` - Payment processing (see `.env.stripe.example`) - -## Code Style - -- Tabs for indentation -- Single quotes for strings -- 100 character line width -- Prettier auto-sorts Tailwind classes +- **Svelte 5 Runes**: Use `$state`, `$derived`, `$effect` — never `$:` +- **Local-first**: All CRUD via `linkCollection.insert/update/delete` (IndexedDB) +- **Analytics**: Fetched from Hono server, not local (server-only click data) +- **Auth**: `authStore` from `@manacore/shared-auth-stores`, `AuthGate` with guest mode +- **Sync**: Starts on login via `uloadStore.startSync()`, stops on logout diff --git a/apps/uload/apps/server/Dockerfile b/apps/uload/apps/server/Dockerfile new file mode 100644 index 000000000..a79f73949 --- /dev/null +++ b/apps/uload/apps/server/Dockerfile @@ -0,0 +1,16 @@ +FROM oven/bun:1 AS production + +WORKDIR /app + +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile 2>/dev/null || bun install + +COPY src ./src +COPY tsconfig.json ./ + +EXPOSE 3041 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD bun -e "fetch('http://localhost:3041/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["bun", "run", "src/index.ts"] diff --git a/apps/uload/apps/web/Dockerfile b/apps/uload/apps/web/Dockerfile new file mode 100644 index 000000000..f188c0382 --- /dev/null +++ b/apps/uload/apps/web/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +FROM sveltekit-base:local AS builder + +ARG PUBLIC_ULOAD_SERVER_URL=http://uload-server:3041 +ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001 +ENV PUBLIC_ULOAD_SERVER_URL=$PUBLIC_ULOAD_SERVER_URL +ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL + +COPY apps/uload/packages/uload-database ./apps/uload/packages/uload-database +COPY apps/uload/apps/web ./apps/uload/apps/web + +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ + pnpm install --no-frozen-lockfile --ignore-scripts + +WORKDIR /app/apps/uload/apps/web +RUN pnpm exec svelte-kit sync +RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build + +FROM node:20-alpine AS production +WORKDIR /app/apps/uload/apps/web +COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm +COPY --from=builder /app/apps/uload/apps/web/node_modules ./node_modules +COPY --from=builder /app/apps/uload/apps/web/build ./build +COPY --from=builder /app/apps/uload/apps/web/package.json ./ + +EXPOSE 5027 +ENV NODE_ENV=production PORT=5027 HOST=0.0.0.0 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:5027/health || exit 1 + +CMD ["node", "build"] diff --git a/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte index 18e2645ba..2d67c313e 100644 --- a/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte +++ b/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte @@ -29,6 +29,10 @@ let newUtmSource = $state(''); let newUtmMedium = $state(''); let newUtmCampaign = $state(''); + let showAdvanced = $state(false); + let newExpiresAt = $state(''); + let newPassword = $state(''); + let newMaxClicks = $state(''); // Edit modal state let editingLink = $state(null); @@ -38,6 +42,13 @@ let editUtmSource = $state(''); let editUtmMedium = $state(''); let editUtmCampaign = $state(''); + let editExpiresAt = $state(''); + let editPassword = $state(''); + let editMaxClicks = $state(''); + + // Bulk selection state + let selectMode = $state(false); + let selectedIds = $state>(new Set()); // QR modal state let qrLink = $state(null); @@ -89,6 +100,9 @@ utmSource: newUtmSource || null, utmMedium: newUtmMedium || null, utmCampaign: newUtmCampaign || null, + expiresAt: newExpiresAt || null, + password: newPassword || null, + maxClicks: newMaxClicks ? parseInt(newMaxClicks) : null, }); toast.success(`Link erstellt: ${shortCode}`); newUrl = ''; @@ -97,7 +111,11 @@ newUtmSource = ''; newUtmMedium = ''; newUtmCampaign = ''; + newExpiresAt = ''; + newPassword = ''; + newMaxClicks = ''; showUtm = false; + showAdvanced = false; } function openEdit(link: LocalLink) { @@ -108,6 +126,9 @@ editUtmSource = link.utmSource ?? ''; editUtmMedium = link.utmMedium ?? ''; editUtmCampaign = link.utmCampaign ?? ''; + editExpiresAt = link.expiresAt ?? ''; + editPassword = link.password ?? ''; + editMaxClicks = link.maxClicks?.toString() ?? ''; } async function saveEdit() { @@ -119,11 +140,55 @@ utmSource: editUtmSource || null, utmMedium: editUtmMedium || null, utmCampaign: editUtmCampaign || null, + expiresAt: editExpiresAt || null, + password: editPassword || null, + maxClicks: editMaxClicks ? parseInt(editMaxClicks) : null, }); toast.success('Link aktualisiert'); editingLink = null; } + // Bulk actions + function toggleSelect(id: string) { + if (selectedIds.has(id)) { + selectedIds.delete(id); + } else { + selectedIds.add(id); + } + selectedIds = selectedIds; + } + + function toggleSelectAll() { + if (selectedIds.size === filteredLinks.length) { + selectedIds.clear(); + } else { + selectedIds = new Set(filteredLinks.map((l) => l.id)); + } + selectedIds = selectedIds; + } + + async function bulkDelete() { + if (!confirm(`${selectedIds.size} Link(s) löschen?`)) return; + for (const id of selectedIds) { + await linkCollection.delete(id); + } + toast.success(`${selectedIds.size} Links gelöscht`); + selectedIds.clear(); + selectedIds = selectedIds; + selectMode = false; + } + + async function bulkToggleActive() { + for (const id of selectedIds) { + const link = filteredLinks.find((l) => l.id === id); + if (link) await linkCollection.update(id, { isActive: !link.isActive }); + } + toast.success(`${selectedIds.size} Links aktualisiert`); + selectedIds.clear(); + selectedIds = selectedIds; + selectMode = false; + } + async function toggleActive(link: LocalLink) { await linkCollection.update(link.id, { isActive: !link.isActive }); } @@ -163,12 +228,28 @@ ({filteredLinks.length}) {/if} - +
+ + +
@@ -210,6 +291,67 @@ + + + {#if showAdvanced} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + + + {/if} + {#if links.loading}
@@ -334,8 +504,16 @@ class="group rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800" >
+ {#if selectMode} + toggleSelect(link.id)} + class="mr-3 h-4 w-4 shrink-0 rounded" + /> + {/if}
-
+
🔒 + {/if} + {#if link.expiresAt} + + {/if} + {#if link.maxClicks} + 🎯 {link.maxClicks} + {/if}

{link.originalUrl}

@@ -524,6 +720,35 @@ />
+ +
+

Erweitert

+
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index b98bfa8be..b1084177b 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -462,7 +462,11 @@ services: restart: always mem_limit: 256m volumes: - - ./services/mana-search/searxng:/etc/searxng:ro + - ./services/mana-search/searxng:/mnt/searxng-config:ro + entrypoint: > + sh -c "cp /mnt/searxng-config/settings.yml /etc/searxng/settings.yml && + cp /mnt/searxng-config/limiter.toml /etc/searxng/limiter.toml 2>/dev/null; + exec /usr/local/searxng/dockerfiles/docker-entrypoint.sh" environment: SEARXNG_BASE_URL: http://searxng:8080 SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-searxng-secret} @@ -1583,16 +1587,19 @@ services: container_name: mana-mon-victoria restart: always mem_limit: 256m - command: - - '-storageDataPath=/storage' - - '-retentionPeriod=2y' - - '-httpListenAddr=:9090' - - '-promscrape.config=/etc/prometheus/prometheus.yml' - - '-promscrape.config.strictParse=false' - - '-selfScrapeInterval=15s' - - '-search.latencyOffset=0s' + entrypoint: > + sh -c "mkdir -p /etc/prometheus && + cp /mnt/prometheus-config/*.yml /etc/prometheus/ 2>/dev/null; + exec /victoria-metrics-prod + -storageDataPath=/storage + -retentionPeriod=2y + -httpListenAddr=:9090 + -promscrape.config=/etc/prometheus/prometheus.yml + -promscrape.config.strictParse=false + -selfScrapeInterval=15s + -search.latencyOffset=0s" volumes: - - ./docker/prometheus:/etc/prometheus:ro + - ./docker/prometheus:/mnt/prometheus-config:ro - victoriametrics_data:/storage ports: - "9090:9090" @@ -1608,9 +1615,12 @@ services: container_name: mana-mon-loki restart: always mem_limit: 192m - command: -config.file=/etc/loki/local-config.yaml + entrypoint: > + sh -c "mkdir -p /etc/loki && + cp /mnt/loki-config/*.yaml /etc/loki/ 2>/dev/null; + exec /usr/bin/loki -config.file=/etc/loki/local-config.yaml" volumes: - - ./docker/loki:/etc/loki:ro + - ./docker/loki:/mnt/loki-config:ro - loki_data:/loki ports: - "3100:3100" @@ -1723,16 +1733,19 @@ services: condition: service_healthy alertmanager: condition: service_healthy - command: - - '-datasource.url=http://victoriametrics:9090' - - '-notifier.url=http://alertmanager:9093' - - '-remoteWrite.url=http://victoriametrics:9090' - - '-remoteRead.url=http://victoriametrics:9090' - - '-rule=/etc/alerts/*.yml' - - '-evaluationInterval=30s' - - '-httpListenAddr=:8880' + entrypoint: > + sh -c "mkdir -p /etc/alerts && + cp /mnt/alerts-config/*.yml /etc/alerts/ 2>/dev/null; + exec /vmalert-prod + -datasource.url=http://victoriametrics:9090 + -notifier.url=http://alertmanager:9093 + -remoteWrite.url=http://victoriametrics:9090 + -remoteRead.url=http://victoriametrics:9090 + -rule='/etc/alerts/*.yml' + -evaluationInterval=30s + -httpListenAddr=:8880" volumes: - - ./docker/prometheus:/etc/alerts:ro + - ./docker/prometheus:/mnt/alerts-config:ro ports: - "8880:8880" healthcheck: @@ -1750,12 +1763,15 @@ services: depends_on: alert-notifier: condition: service_healthy - command: - - '--config.file=/etc/alertmanager/alertmanager.yml' - - '--storage.path=/alertmanager' - - '--web.listen-address=:9093' + entrypoint: > + sh -c "mkdir -p /tmp/am-config && + cp /mnt/alertmanager-config/*.yml /tmp/am-config/ 2>/dev/null; + exec /bin/alertmanager + --config.file=/tmp/am-config/alertmanager.yml + --storage.path=/alertmanager + --web.listen-address=:9093" volumes: - - ./docker/alertmanager:/etc/alertmanager:ro + - ./docker/alertmanager:/mnt/alertmanager-config:ro - alertmanager_data:/alertmanager ports: - "9093:9093" diff --git a/package.json b/package.json index 9c5a3b368..97bd09fec 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,7 @@ "docker:logs:chat": "docker compose -f docker-compose.dev.yml --env-file .env.development logs -f chat-backend", "docker:ps": "docker compose -f docker-compose.dev.yml --env-file .env.development ps -a", "docker:clean": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile all down -v", + "deploy:landing:uload": "pnpm --filter @uload/landing build && npx wrangler pages deploy apps/uload/apps/landing/dist --project-name=uload-landing", "deploy:landing:todo": "pnpm --filter @todo/landing build && npx wrangler pages deploy apps/todo/apps/landing/dist --project-name=todo-landing", "deploy:landing:calendar": "pnpm --filter @calendar/landing build && npx wrangler pages deploy apps/calendar/apps/landing/dist --project-name=calendars-landing", "deploy:landing:chat": "pnpm --filter @chat/landing build && npx wrangler pages deploy apps/chat/apps/landing/dist --project-name=chat-landing",