diff --git a/apps/uload/.env.example b/apps/uload/.env.example
new file mode 100644
index 000000000..2f7f0c241
--- /dev/null
+++ b/apps/uload/.env.example
@@ -0,0 +1,36 @@
+# SvelteKit Configuration
+PORT=3000
+ORIGIN=https://your-domain.com
+NODE_ENV=production
+PUBLIC_APP_URL=https://ulo.ad
+
+# Database (PostgreSQL)
+# Development: Use local Docker container
+DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
+# Production: Use your Coolify/Hetzner PostgreSQL container
+# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
+
+# File Storage (Cloudflare R2)
+R2_ACCOUNT_ID=your_cloudflare_account_id
+R2_ACCESS_KEY_ID=your_r2_access_key
+R2_SECRET_ACCESS_KEY=your_r2_secret_key
+R2_BUCKET_AVATARS=uload-avatars
+R2_BUCKET_QR=uload-qr-codes
+R2_PUBLIC_URL=https://files.ulo.ad
+
+# Email (Resend)
+RESEND_API_KEY=re_your_resend_api_key
+RESEND_FROM_EMAIL=noreply@ulo.ad
+
+# Umami Analytics (optional)
+PUBLIC_UMAMI_URL=https://your-umami-instance.com
+PUBLIC_UMAMI_WEBSITE_ID=your-website-id
+
+# External Auth (to be implemented)
+# AUTH_PROVIDER_CLIENT_ID=
+# AUTH_PROVIDER_CLIENT_SECRET=
+
+# Coolify specific (if needed)
+# These will be set automatically by Coolify
+# COOLIFY_URL=
+# COOLIFY_TOKEN=
diff --git a/apps/uload/.env.production.example b/apps/uload/.env.production.example
new file mode 100644
index 000000000..697f30661
--- /dev/null
+++ b/apps/uload/.env.production.example
@@ -0,0 +1,20 @@
+# SvelteKit Configuration
+NODE_ENV=production
+PORT=3000
+ORIGIN=https://your-domain.com
+PUBLIC_POCKETBASE_URL=https://your-domain.com/api
+
+# PocketBase Admin Credentials
+# These will be used to create the admin on first startup
+POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
+POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
+
+# Umami Analytics
+# Replace with your actual Umami instance and website ID
+PUBLIC_UMAMI_URL=https://your-umami-instance.com
+PUBLIC_UMAMI_WEBSITE_ID=your-website-id
+
+# Optional: Additional Configuration
+# BODY_SIZE_LIMIT=512kb
+# PROTOCOL_HEADER=x-forwarded-proto
+# HOST_HEADER=x-forwarded-host
\ No newline at end of file
diff --git a/apps/uload/.env.stripe.example b/apps/uload/.env.stripe.example
new file mode 100644
index 000000000..e3682dd3e
--- /dev/null
+++ b/apps/uload/.env.stripe.example
@@ -0,0 +1,17 @@
+# Stripe Configuration
+# Copy this to .env.local or add to your .env file
+
+# Stripe API Keys (get from https://dashboard.stripe.com/test/apikeys)
+PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
+STRIPE_SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
+
+# Stripe Product & Price IDs (will be created automatically by Claude)
+STRIPE_PRODUCT_PRO=prod_xxx
+STRIPE_PRICE_MONTHLY=price_xxx
+STRIPE_PRICE_YEARLY=price_xxx
+
+# Stripe Webhook Secret (from webhook endpoint in dashboard)
+STRIPE_WEBHOOK_SECRET=whsec_xxx
+
+# App URL for redirects
+PUBLIC_APP_URL=http://localhost:5173 # Production: https://ulo.ad
\ No newline at end of file
diff --git a/apps/uload/.gitignore b/apps/uload/.gitignore
new file mode 100644
index 000000000..cdb4c4d46
--- /dev/null
+++ b/apps/uload/.gitignore
@@ -0,0 +1,43 @@
+# Dependencies
+node_modules
+
+# Test results
+test-results
+
+# Build output
+.output
+.vercel
+.netlify
+.wrangler
+.svelte-kit
+build
+dist
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Environment files
+.env
+.env.*
+!.env.example
+!.env.*.example
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+# MCP Configuration with credentials
+.mcp.json
+.mcp.json-dev
+
+# PocketBase
+backend/pocketbase
+backend/pb_data/
+*.log
+
+# IDE
+.idea
+.vscode
+*.swp
+*.swo
diff --git a/apps/uload/CLAUDE.md b/apps/uload/CLAUDE.md
new file mode 100644
index 000000000..578aecd42
--- /dev/null
+++ b/apps/uload/CLAUDE.md
@@ -0,0 +1,132 @@
+# 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.
+
+**Live:** https://ulo.ad
+
+## Project Structure
+
+```
+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
+```
+
+## 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
+```
+
+### Build & Deploy
+
+```bash
+pnpm run build # Create production build
+```
+
+### 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
diff --git a/apps/uload/README.md b/apps/uload/README.md
new file mode 100644
index 000000000..bde7ba971
--- /dev/null
+++ b/apps/uload/README.md
@@ -0,0 +1,151 @@
+# uLoad - URL Shortener & Link Management
+
+A modern URL shortener and link management platform built with SvelteKit and PocketBase.
+
+## 🚀 Production
+
+**Live:** https://ulo.ad
+**Admin:** https://ulo.ad/_/
+
+## 🛠 Tech Stack
+
+- **Frontend:** SvelteKit 2.0 + Svelte 5
+- **Backend:** PocketBase (embedded)
+- **Styling:** Tailwind CSS 4.0
+- **Deployment:** Docker + Coolify on Hetzner VPS
+- **Database:** SQLite (via PocketBase)
+
+## 📦 Features
+
+- URL shortening with custom codes
+- QR code generation
+- Click analytics
+- User profiles (e.g., ulo.ad/p/username)
+- Link management dashboard
+- Real-time statistics
+
+## 🏃 Development
+
+```bash
+# Install dependencies
+npm install --legacy-peer-deps
+
+# Start development server
+npm run dev
+
+# Start with PocketBase backend
+npm run dev:all
+
+# Run tests
+npm run test
+
+# Type checking
+npm run check
+```
+
+## 🐳 Docker Deployment
+
+```bash
+# Build and run locally
+docker-compose up --build
+
+# Access at:
+# Frontend: http://localhost:3000
+# PocketBase: http://localhost:8090
+```
+
+## 📝 Documentation
+
+- [Deployment Guide](./DEPLOYMENT.md) - Complete Docker Compose deployment instructions
+- [Lessons Learned](./DEPLOYMENT_LESSONS_LEARNED.md) - Troubleshooting and insights
+- [Domain Setup](./DOMAIN_SETUP_ULO_AD.md) - ulo.ad configuration
+- [Coolify Setup](./COOLIFY_SETUP.md) - Detailed Coolify configuration
+
+## 🔧 Environment Variables
+
+```bash
+NODE_ENV=production
+PORT=3000
+ORIGIN=https://ulo.ad
+PUBLIC_POCKETBASE_URL=https://ulo.ad/api
+POCKETBASE_ADMIN_EMAIL=admin@example.com
+POCKETBASE_ADMIN_PASSWORD=secure_password
+```
+
+See `.env.example` for all configuration options.
+
+## 📂 Project Structure
+
+```
+uload/
+├── src/ # SvelteKit application
+│ ├── routes/ # Pages and API routes
+│ ├── lib/ # Components and utilities
+│ └── app.html # HTML template
+├── backend/ # PocketBase configuration
+│ ├── pb_schema.json # Database schema
+│ └── init-pocketbase.sh # Setup script
+├── build/ # Production build output
+├── static/ # Static assets
+├── Dockerfile # Multi-stage Docker build
+├── docker-compose.yml # Local development
+├── supervisord.conf # Process management
+└── CLAUDE.md # AI assistant context
+```
+
+## 🚢 Deployment
+
+The application is deployed on Hetzner VPS using Coolify with automatic deployments on push to main branch.
+
+```bash
+# Commit and push to deploy
+git add .
+git commit -m "Update"
+git push origin main
+# Coolify automatically deploys
+```
+
+### Manual Deployment Steps:
+
+1. Set DNS A record to `91.99.221.179`
+2. Add domain in Coolify
+3. Update environment variables
+4. Enable SSL certificate
+5. Deploy application
+
+## 📊 Monitoring
+
+- **Health Check:** https://ulo.ad/health
+- **Admin Panel:** https://ulo.ad/_/
+- **Server:** Hetzner CX21 (2 vCPU, 4GB RAM)
+- **Uptime:** 99.9% SLA
+
+## 🔐 Security
+
+- HTTPS enforced
+- Environment-based configuration
+- Secure admin authentication
+- Rate limiting on API endpoints
+- Regular security updates
+
+## 🤝 Contributing
+
+1. Fork the repository
+2. Create your feature branch (`git checkout -b feature/amazing-feature`)
+3. Commit your changes (`git commit -m 'Add amazing feature'`)
+4. Push to the branch (`git push origin feature/amazing-feature`)
+5. Open a Pull Request
+
+## 🐛 Troubleshooting
+
+Common issues and solutions are documented in [DEPLOYMENT_LESSONS_LEARNED.md](./DEPLOYMENT_LESSONS_LEARNED.md)
+
+For support, check:
+
+- Application logs in Coolify
+- Health endpoint status
+- PocketBase admin panel
+
+## 📄 License
+
+Private - Memoro AI © 2024
diff --git a/apps/uload/apps/landing/astro.config.mjs b/apps/uload/apps/landing/astro.config.mjs
new file mode 100644
index 000000000..5721576c6
--- /dev/null
+++ b/apps/uload/apps/landing/astro.config.mjs
@@ -0,0 +1,16 @@
+import { defineConfig } from 'astro/config';
+import tailwind from '@astrojs/tailwind';
+import mdx from '@astrojs/mdx';
+import sitemap from '@astrojs/sitemap';
+
+export default defineConfig({
+ site: 'https://ulo.ad',
+ integrations: [tailwind(), mdx(), sitemap()],
+ i18n: {
+ defaultLocale: 'de',
+ locales: ['de', 'en'],
+ routing: {
+ prefixDefaultLocale: false,
+ },
+ },
+});
diff --git a/apps/uload/apps/landing/package.json b/apps/uload/apps/landing/package.json
new file mode 100644
index 000000000..88cfab93c
--- /dev/null
+++ b/apps/uload/apps/landing/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@uload/landing",
+ "type": "module",
+ "version": "1.0.0",
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro",
+ "check": "astro check"
+ },
+ "dependencies": {
+ "@astrojs/check": "^0.9.4",
+ "@astrojs/mdx": "^4.0.8",
+ "@astrojs/sitemap": "^3.2.1",
+ "@astrojs/tailwind": "^6.0.2",
+ "@manacore/shared-landing-ui": "workspace:*",
+ "astro": "^5.1.1",
+ "tailwindcss": "^3.4.17"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/apps/uload/apps/landing/src/components/Footer.astro b/apps/uload/apps/landing/src/components/Footer.astro
new file mode 100644
index 000000000..adbcb23be
--- /dev/null
+++ b/apps/uload/apps/landing/src/components/Footer.astro
@@ -0,0 +1,114 @@
+---
+const currentYear = new Date().getFullYear();
+
+const footerLinks = {
+ produkt: [
+ { href: '/features', label: 'Features' },
+ { href: '/#pricing', label: 'Preise' },
+ { href: '/blog', label: 'Blog' },
+ ],
+ unternehmen: [{ href: '/about', label: 'Über uns' }],
+ rechtliches: [
+ { href: '/datenschutz', label: 'Datenschutz' },
+ { href: '/impressum', label: 'Impressum' },
+ { href: '/agb', label: 'AGB' },
+ { href: '/sicherheit', label: 'Sicherheit' },
+ ],
+};
+
+const appUrl = 'https://app.ulo.ad';
+---
+
+
+
+
+
+
+
+
+ u
+
+ uLoad
+
+
+ Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und
+ analysieren Sie Klicks.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © {currentYear} uLoad. Alle Rechte vorbehalten.
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/components/HeroSection.astro b/apps/uload/apps/landing/src/components/HeroSection.astro
new file mode 100644
index 000000000..b57d1ad8e
--- /dev/null
+++ b/apps/uload/apps/landing/src/components/HeroSection.astro
@@ -0,0 +1,195 @@
+---
+const appUrl = 'https://app.ulo.ad';
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DSGVO-konform
+
+
+
+
+
+ Blitzschnell
+
+
+
+
+
+ 100% Sicher
+
+
+
+
+
+ More than links.
+
+ Your digital identity.
+
+
+
+
+ Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links,
+ beeindruckende Profilkarten und manage alles im Team.
+
+
+
+
+
+
+
+
+
+ Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
+
+
+
+
+
+
+
+
+
+
Smart Links
+
Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz
+
+ Mehr erfahren →
+
+
+
+
+
+
+
+
+
+
Team Workspace
+
Gemeinsam Links verwalten mit granularen Berechtigungen
+
+ Für Teams →
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/components/Navigation.astro b/apps/uload/apps/landing/src/components/Navigation.astro
new file mode 100644
index 000000000..7fb1f5f16
--- /dev/null
+++ b/apps/uload/apps/landing/src/components/Navigation.astro
@@ -0,0 +1,86 @@
+---
+const navLinks = [
+ { href: '/features', label: 'Features' },
+ { href: '/blog', label: 'Blog' },
+ { href: '/about', label: 'Über uns' },
+];
+
+const appUrl = 'https://app.ulo.ad';
+---
+
+
+
+
diff --git a/apps/uload/apps/landing/src/content/blog/link-tracking-guide.md b/apps/uload/apps/landing/src/content/blog/link-tracking-guide.md
new file mode 100644
index 000000000..a4134ca62
--- /dev/null
+++ b/apps/uload/apps/landing/src/content/blog/link-tracking-guide.md
@@ -0,0 +1,92 @@
+---
+title: Der ultimative Link-Tracking Guide für 2024
+description: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben.
+pubDate: 2024-01-20
+author: Till Schneider
+tags: [tracking, analytics, dsgvo, marketing]
+---
+
+Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern.
+
+## Was ist Link-Tracking?
+
+Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
+
+- Woher kommen Ihre Besucher?
+- Welche Kampagnen funktionieren?
+- Wie hoch ist Ihre Conversion-Rate?
+- Welche Inhalte performen am besten?
+
+## Die wichtigsten Metriken
+
+### 1. Click-Through-Rate (CTR)
+
+Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%.
+
+### 2. Conversion Rate
+
+Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen.
+
+### 3. Bounce Rate
+
+Wie viele Nutzer verlassen Ihre Seite sofort wieder?
+
+### 4. Geographic Distribution
+
+Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen.
+
+## UTM-Parameter richtig einsetzen
+
+UTM-Parameter sind der Standard für Campaign-Tracking:
+
+```
+https://ulo.ad/angebot
+?utm_source=newsletter
+&utm_medium=email
+&utm_campaign=winter-sale
+```
+
+### Die 5 UTM-Parameter
+
+1. **utm_source**: Woher kommt der Traffic?
+2. **utm_medium**: Welches Medium?
+3. **utm_campaign**: Welche Kampagne?
+4. **utm_content**: Welcher spezifische Link?
+5. **utm_term**: Welches Keyword?
+
+## DSGVO-konformes Tracking
+
+### Was ist erlaubt?
+
+✅ **Anonymisierte Daten**
+
+- Gerätetyp
+- Browser
+- Ungefährer Standort
+- Referrer
+
+### Was braucht Zustimmung?
+
+❌ **Personenbezogene Daten**
+
+- Vollständige IP-Adressen
+- Device Fingerprinting
+- Cross-Site Tracking
+
+## Best Practices für Link-Tracking
+
+### 1. Konsistente Namenskonvention
+
+Entwickeln Sie ein einheitliches Schema für Ihre Kampagnen.
+
+### 2. Dokumentation führen
+
+Erstellen Sie eine Tracking-Tabelle für alle Kampagnen.
+
+### 3. Regelmäßige Bereinigung
+
+Löschen Sie alte, inaktive Links regelmäßig.
+
+## Fazit
+
+Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern.
diff --git a/apps/uload/apps/landing/src/content/blog/psychologie-kurzer-urls.md b/apps/uload/apps/landing/src/content/blog/psychologie-kurzer-urls.md
new file mode 100644
index 000000000..e05de2c8d
--- /dev/null
+++ b/apps/uload/apps/landing/src/content/blog/psychologie-kurzer-urls.md
@@ -0,0 +1,76 @@
+---
+title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt
+description: 42% weniger Klicks bei langen URLs – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter.
+pubDate: 2024-01-15
+author: Till Schneider
+tags: [urls, psychology, conversion, marketing]
+---
+
+**42% weniger Klicks bei langen URLs** – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können.
+
+## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen
+
+Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link – nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance.
+
+### Die Spam-Alarm-Reaktion unseres Gehirns
+
+Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen.
+
+Vergleichen Sie diese beiden URLs:
+
+**Lange URL (schlecht):**
+
+```
+https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024
+```
+
+**Kurze URL (gut):**
+
+```
+https://ulo.ad/summer-sale
+```
+
+### Mobile Nutzer: Die vergessene Mehrheit
+
+In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen.
+
+## Die Wissenschaft dahinter: Cognitive Load Theory
+
+Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands.
+
+## Die vier Säulen des Link-Vertrauens
+
+1. **Erkennbare Domain (60% Wichtigkeit)** - Menschen wollen wissen, wo sie landen werden
+2. **Keine kryptischen Zeichen (25% Wichtigkeit)** - Zufällige Zahlen-Buchstaben-Kombinationen schrecken ab
+3. **Optimale Länge (10% Wichtigkeit)** - Die magische Grenze liegt bei etwa 50 Zeichen
+4. **HTTPS-Verschlüsselung (5% Wichtigkeit)** - Ein Hygienefaktor
+
+## Praktische Optimierungsstrategien
+
+### 1. Sprechende URLs verwenden
+
+❌ **Schlecht:** `ulo.ad/p47829`
+✅ **Gut:** `ulo.ad/sommer-sale`
+
+### 2. Die 50-Zeichen-Regel
+
+Halten Sie Ihre URLs unter 50 Zeichen. Das ist:
+
+- Kurz genug für Twitter/X
+- Lesbar auf Mobilgeräten
+- Merkbar für Nutzer
+
+### 3. A/B-Testing ist Ihr Freund
+
+Testen Sie verschiedene URL-Varianten und messen Sie die Performance.
+
+## Fazit: Die Macht der Kürze
+
+Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen.
+
+### Die wichtigsten Takeaways
+
+1. **42% weniger Klicks** bei URLs über 100 Zeichen
+2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit
+3. **50 Zeichen** ist die magische Grenze
+4. **Sprechende URLs** performen 39% besser
diff --git a/apps/uload/apps/landing/src/content/config.ts b/apps/uload/apps/landing/src/content/config.ts
new file mode 100644
index 000000000..858ec45ce
--- /dev/null
+++ b/apps/uload/apps/landing/src/content/config.ts
@@ -0,0 +1,17 @@
+import { defineCollection, z } from 'astro:content';
+
+const blogCollection = defineCollection({
+ type: 'content',
+ schema: z.object({
+ title: z.string(),
+ description: z.string(),
+ pubDate: z.date(),
+ author: z.string().optional(),
+ image: z.string().optional(),
+ tags: z.array(z.string()).optional(),
+ }),
+});
+
+export const collections = {
+ blog: blogCollection,
+};
diff --git a/apps/uload/apps/landing/src/env.d.ts b/apps/uload/apps/landing/src/env.d.ts
new file mode 100644
index 000000000..acef35f17
--- /dev/null
+++ b/apps/uload/apps/landing/src/env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/apps/uload/apps/landing/src/layouts/BaseLayout.astro b/apps/uload/apps/landing/src/layouts/BaseLayout.astro
new file mode 100644
index 000000000..081344dfb
--- /dev/null
+++ b/apps/uload/apps/landing/src/layouts/BaseLayout.astro
@@ -0,0 +1,59 @@
+---
+import '../styles/global.css';
+import Navigation from '../components/Navigation.astro';
+import Footer from '../components/Footer.astro';
+
+interface Props {
+ title: string;
+ description?: string;
+ ogImage?: string;
+}
+
+const {
+ title,
+ description = 'uLoad - Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks.',
+ ogImage = '/og-image.png',
+} = Astro.props;
+const canonicalURL = new URL(Astro.url.pathname, Astro.site);
+---
+
+
+
+
+
+
+
+
+
+ {title} | uLoad
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/layouts/LegalLayout.astro b/apps/uload/apps/landing/src/layouts/LegalLayout.astro
new file mode 100644
index 000000000..ddfee3fd6
--- /dev/null
+++ b/apps/uload/apps/landing/src/layouts/LegalLayout.astro
@@ -0,0 +1,28 @@
+---
+import BaseLayout from './BaseLayout.astro';
+
+interface Props {
+ title: string;
+ description?: string;
+ lastUpdated?: string;
+}
+
+const { title, description, lastUpdated } = Astro.props;
+---
+
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/pages/about.astro b/apps/uload/apps/landing/src/pages/about.astro
new file mode 100644
index 000000000..d8115ce13
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/about.astro
@@ -0,0 +1,130 @@
+---
+import BaseLayout from '../layouts/BaseLayout.astro';
+
+const stats = [
+ { value: '10K+', label: 'Aktive Nutzer' },
+ { value: '500K+', label: 'Erstellte Links' },
+ { value: '2M+', label: 'Klicks verfolgt' },
+ { value: '99.9%', label: 'Uptime' },
+];
+
+const values = [
+ {
+ icon: '🎯',
+ title: 'Einfachheit',
+ description:
+ 'Wir glauben, dass professionelle Tools nicht kompliziert sein müssen. uLoad ist intuitiv und sofort einsatzbereit.',
+ },
+ {
+ icon: '🔒',
+ title: 'Datenschutz',
+ description:
+ 'Ihre Daten gehören Ihnen. Wir sind DSGVO-konform und speichern nur was wirklich notwendig ist.',
+ },
+ {
+ icon: '⚡',
+ title: 'Performance',
+ description:
+ 'Schnelle Links bedeuten bessere Nutzererfahrung. Unsere Infrastruktur ist auf Geschwindigkeit optimiert.',
+ },
+ {
+ icon: '💪',
+ title: 'Zuverlässigkeit',
+ description:
+ 'Mit 99.9% Uptime können Sie sich auf uLoad verlassen - für jede Kampagne, jedes Projekt.',
+ },
+];
+---
+
+
+
+
+
+
+
+ Links die verbinden
+
+
+ uLoad wurde entwickelt um Link-Management einfach, sicher und effektiv zu machen. Für
+ Einzelpersonen, Teams und Unternehmen.
+
+
+
+
+
+
+
+
+
+ {
+ stats.map((stat) => (
+
+
{stat.value}
+
{stat.label}
+
+ ))
+ }
+
+
+
+
+
+
+
+
Unsere Geschichte
+
+
+ uLoad entstand aus einer einfachen Frustration: Bestehende URL-Shortener waren entweder zu
+ kompliziert, zu teuer oder boten nicht die Features die moderne Teams brauchen.
+
+
+ Wir wollten einen Service schaffen, der sowohl für Einsteiger als auch für Power-User
+ funktioniert. Ein Tool das mit Ihren Anforderungen wächst - von der ersten verkürzten URL
+ bis zum Enterprise-Einsatz.
+
+
+ Heute nutzen tausende Nutzer uLoad täglich für ihre Marketing-Kampagnen,
+ Social-Media-Posts und geschäftliche Kommunikation. Und wir arbeiten jeden Tag daran,
+ uLoad noch besser zu machen.
+
+
+
+
+
+
+
+
+
Unsere Werte
+
+ {
+ values.map((value) => (
+
+
{value.icon}
+
{value.title}
+
{value.description}
+
+ ))
+ }
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/pages/agb.astro b/apps/uload/apps/landing/src/pages/agb.astro
new file mode 100644
index 000000000..f1160e269
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/agb.astro
@@ -0,0 +1,76 @@
+---
+import LegalLayout from '../layouts/LegalLayout.astro';
+---
+
+
+ § 1 Geltungsbereich
+
+ Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für alle Verträge zwischen uLoad und dem
+ Nutzer über die Nutzung der auf der Website ulo.ad angebotenen Dienste.
+
+
+ § 2 Leistungsbeschreibung
+
+ uLoad bietet einen URL-Verkürzungsdienst sowie ergänzende Dienste wie Analytics,
+ QR-Code-Generierung und Team-Workspaces an. Der genaue Leistungsumfang ergibt sich aus der
+ jeweiligen Produktbeschreibung zum Zeitpunkt der Bestellung.
+
+
+ § 3 Registrierung und Nutzerkonto
+
+ Für die Nutzung bestimmter Funktionen ist eine Registrierung erforderlich. Der Nutzer
+ verpflichtet sich, wahrheitsgemäße Angaben zu machen und diese aktuell zu halten. Der Nutzer ist
+ für die Geheimhaltung seiner Zugangsdaten verantwortlich.
+
+
+ § 4 Nutzungsregeln
+
+ Der Nutzer verpflichtet sich, den Dienst nicht für rechtswidrige Zwecke zu nutzen. Insbesondere
+ ist es untersagt:
+
+
+ Links zu illegalen Inhalten zu erstellen
+ Spam oder Phishing-Links zu verbreiten
+ Die Dienste für automatisierte Massenanfragen zu missbrauchen
+ Andere Nutzer zu belästigen oder zu täuschen
+
+
+ § 5 Preise und Zahlung
+
+ Die Nutzung der Basisfunktionen ist kostenlos. Für erweiterte Funktionen können kostenpflichtige
+ Abonnements abgeschlossen werden. Alle Preise verstehen sich inklusive der gesetzlichen
+ Mehrwertsteuer.
+
+
+ § 6 Kündigung
+
+ Kostenlose Konten können jederzeit gelöscht werden. Kostenpflichtige Abonnements können zum Ende
+ der jeweiligen Abrechnungsperiode gekündigt werden.
+
+
+ § 7 Haftung
+
+ uLoad haftet nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen.
+ Die Haftung für leichte Fahrlässigkeit ist ausgeschlossen, soweit nicht wesentliche
+ Vertragspflichten verletzt wurden.
+
+
+ § 8 Datenschutz
+
+ Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung und den
+ geltenden Datenschutzgesetzen.
+
+
+ § 9 Änderungen der AGB
+
+ uLoad behält sich vor, diese AGB jederzeit zu ändern. Änderungen werden dem Nutzer rechtzeitig
+ mitgeteilt. Mit der weiteren Nutzung des Dienstes nach Inkrafttreten der Änderungen erklärt sich
+ der Nutzer mit diesen einverstanden.
+
+
+ § 10 Schlussbestimmungen
+
+ Es gilt das Recht der Bundesrepublik Deutschland. Sollten einzelne Bestimmungen dieser AGB
+ unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.
+
+
diff --git a/apps/uload/apps/landing/src/pages/blog/[slug].astro b/apps/uload/apps/landing/src/pages/blog/[slug].astro
new file mode 100644
index 000000000..df8d5d5e6
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/blog/[slug].astro
@@ -0,0 +1,95 @@
+---
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import { getCollection } from 'astro:content';
+import type { CollectionEntry } from 'astro:content';
+
+export async function getStaticPaths() {
+ const posts = await getCollection('blog');
+ return posts.map((post) => ({
+ params: { slug: post.slug },
+ props: { post },
+ }));
+}
+
+type Props = { post: CollectionEntry<'blog'> };
+const { post } = Astro.props;
+const { Content } = await post.render();
+
+function formatDate(date: Date): string {
+ return new Intl.DateTimeFormat('de-DE', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(date);
+}
+---
+
+
+
+
+
+
+
+
+
+
+ Zurück zum Blog
+
+
+ {post.data.title}
+
+
+
+ {formatDate(post.data.pubDate)}
+
+ {
+ post.data.author && (
+ <>
+ •
+ {post.data.author}
+ >
+ )
+ }
+
+ {
+ post.data.tags && (
+
+ {post.data.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )
+ }
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/pages/blog/index.astro b/apps/uload/apps/landing/src/pages/blog/index.astro
new file mode 100644
index 000000000..20e4483dc
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/blog/index.astro
@@ -0,0 +1,69 @@
+---
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import { getCollection } from 'astro:content';
+
+const posts = (await getCollection('blog')).sort(
+ (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
+);
+
+function formatDate(date: Date): string {
+ return new Intl.DateTimeFormat('de-DE', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(date);
+}
+---
+
+
+
+
+
+
Blog
+
+ Tipps, Tricks und Best Practices rund um Link-Management und digitales Marketing.
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/pages/datenschutz.astro b/apps/uload/apps/landing/src/pages/datenschutz.astro
new file mode 100644
index 000000000..774ae9d31
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/datenschutz.astro
@@ -0,0 +1,91 @@
+---
+import LegalLayout from '../layouts/LegalLayout.astro';
+---
+
+
+ 1. Datenschutz auf einen Blick
+
+ Allgemeine Hinweise
+
+ Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
+ Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit
+ denen Sie persönlich identifiziert werden können.
+
+
+ Datenerfassung auf dieser Website
+
+ Wer ist verantwortlich für die Datenerfassung auf dieser Website?
+ Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten
+ können Sie dem Impressum dieser Website entnehmen.
+
+
+ Wie erfassen wir Ihre Daten?
+
+ Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich
+ z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.
+
+
+ Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst. Das
+ sind vor allem technische Daten (z.B. Internetbrowser, Betriebssystem oder Uhrzeit des
+ Seitenaufrufs).
+
+
+ 2. Hosting
+ Wir hosten die Inhalte unserer Website bei folgendem Anbieter:
+
+ Die Server befinden sich in Deutschland und unterliegen den strengen deutschen
+ Datenschutzgesetzen.
+
+
+ 3. Allgemeine Hinweise und Pflichtinformationen
+
+ Datenschutz
+
+ Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln
+ Ihre personenbezogenen Daten vertraulich und entsprechend den gesetzlichen
+ Datenschutzvorschriften sowie dieser Datenschutzerklärung.
+
+
+ Hinweis zur verantwortlichen Stelle
+
+ Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist im Impressum
+ genannt.
+
+
+ 4. Datenerfassung auf dieser Website
+
+ Cookies
+
+ Unsere Internetseiten verwenden so genannte „Cookies". Cookies sind kleine Datenpakete und
+ richten auf Ihrem Endgerät keinen Schaden an. Sie werden entweder vorübergehend für die Dauer
+ einer Sitzung (Session-Cookies) oder dauerhaft (permanente Cookies) auf Ihrem Endgerät
+ gespeichert.
+
+
+ Server-Log-Dateien
+
+ Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten
+ Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
+
+
+ Browsertyp und Browserversion
+ verwendetes Betriebssystem
+ Referrer URL
+ Hostname des zugreifenden Rechners
+ Uhrzeit der Serveranfrage
+ IP-Adresse (anonymisiert)
+
+
+ 5. Ihre Rechte
+
+ Sie haben jederzeit das Recht, unentgeltlich Auskunft über Herkunft, Empfänger und Zweck Ihrer
+ gespeicherten personenbezogenen Daten zu erhalten. Sie haben außerdem ein Recht, die
+ Berichtigung oder Löschung dieser Daten zu verlangen.
+
+
+ 6. Kontakt
+
+ Bei Fragen zum Datenschutz können Sie sich jederzeit an uns wenden. Die Kontaktdaten finden Sie
+ im Impressum.
+
+
diff --git a/apps/uload/apps/landing/src/pages/features.astro b/apps/uload/apps/landing/src/pages/features.astro
new file mode 100644
index 000000000..1533e44d4
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/features.astro
@@ -0,0 +1,169 @@
+---
+import BaseLayout from '../layouts/BaseLayout.astro';
+
+const appUrl = 'https://app.ulo.ad';
+
+const featureCategories = [
+ {
+ title: 'Link Management',
+ features: [
+ {
+ icon: '🔗',
+ title: 'URL-Verkürzung',
+ description:
+ 'Verwandeln Sie lange URLs in kurze, merkbare Links. Perfekt für Social Media, E-Mails und gedruckte Materialien.',
+ },
+ {
+ icon: '✏️',
+ title: 'Custom Short Codes',
+ description:
+ 'Erstellen Sie personalisierte Kurz-URLs wie ulo.ad/mein-link für bessere Wiedererkennung.',
+ },
+ {
+ icon: '📅',
+ title: 'Ablaufdatum',
+ description:
+ 'Setzen Sie automatische Ablaufdaten für zeitlich begrenzte Aktionen und Kampagnen.',
+ },
+ {
+ icon: '🔒',
+ title: 'Passwortschutz',
+ description: 'Schützen Sie sensible Links mit Passwörtern für zusätzliche Sicherheit.',
+ },
+ ],
+ },
+ {
+ title: 'Analytics & Tracking',
+ features: [
+ {
+ icon: '📊',
+ title: 'Klick-Tracking',
+ description: 'Verfolgen Sie jeden Klick in Echtzeit mit detaillierten Statistiken.',
+ },
+ {
+ icon: '🌍',
+ title: 'Geografische Daten',
+ description: 'Sehen Sie woher Ihre Besucher kommen mit Länder- und Städte-Aufschlüsselung.',
+ },
+ {
+ icon: '📱',
+ title: 'Geräte-Analyse',
+ description:
+ 'Erfahren Sie welche Geräte, Browser und Betriebssysteme Ihre Nutzer verwenden.',
+ },
+ {
+ icon: '📈',
+ title: 'Referrer-Tracking',
+ description:
+ 'Identifizieren Sie die Quellen Ihres Traffics für bessere Marketing-Entscheidungen.',
+ },
+ ],
+ },
+ {
+ title: 'QR-Codes',
+ features: [
+ {
+ icon: '🎨',
+ title: 'Anpassbare Designs',
+ description: 'Erstellen Sie QR-Codes in Ihren Markenfarben für konsistentes Branding.',
+ },
+ {
+ icon: '📐',
+ title: 'Multiple Formate',
+ description: 'Download in PNG, SVG oder PDF für verschiedene Anwendungsfälle.',
+ },
+ {
+ icon: '⬇️',
+ title: 'Hochauflösend',
+ description: 'Druckqualität bis zu 4000x4000 Pixel für großformatige Medien.',
+ },
+ ],
+ },
+ {
+ title: 'Team & Kollaboration',
+ features: [
+ {
+ icon: '👥',
+ title: 'Team Workspaces',
+ description: 'Erstellen Sie gemeinsame Arbeitsbereiche für Ihr Team oder Ihre Kunden.',
+ },
+ {
+ icon: '🔐',
+ title: 'Rollenbasierte Rechte',
+ description: 'Definieren Sie wer Links erstellen, bearbeiten oder nur ansehen darf.',
+ },
+ {
+ icon: '🏷️',
+ title: 'Tag-System',
+ description: 'Organisieren Sie Links mit Tags für bessere Übersicht in großen Teams.',
+ },
+ ],
+ },
+];
+---
+
+
+
+
+
+
+ Features die den Unterschied machen
+
+
+ Von einfacher URL-Verkürzung bis hin zu detaillierten Analytics – uLoad bietet alles was
+ Profis brauchen.
+
+
+
+
+
+
+ {
+ featureCategories.map((category, idx) => (
+
+
+
{category.title}
+
+ {category.features.map((feature) => (
+
+
{feature.icon}
+
{feature.title}
+
{feature.description}
+
+ ))}
+
+
+
+ ))
+ }
+
+
+
+
diff --git a/apps/uload/apps/landing/src/pages/impressum.astro b/apps/uload/apps/landing/src/pages/impressum.astro
new file mode 100644
index 000000000..c8b0aa0e9
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/impressum.astro
@@ -0,0 +1,63 @@
+---
+import LegalLayout from '../layouts/LegalLayout.astro';
+---
+
+
+ Angaben gemäß § 5 TMG
+
+
+ uLoad
+ [Ihr Name / Firmenname]
+ [Straße und Hausnummer]
+ [PLZ Ort]
+ Deutschland
+
+
+ Kontakt
+ E-Mail: kontakt@ulo.ad
+
+ Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
+
+ [Ihr Name]
+ [Adresse wie oben]
+
+
+ EU-Streitschlichtung
+
+ Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
+ https://ec.europa.eu/consumers/odr/
+
+ Unsere E-Mail-Adresse finden Sie oben im Impressum.
+
+ Verbraucherstreitbeilegung / Universalschlichtungsstelle
+
+ Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
+ Verbraucherschlichtungsstelle teilzunehmen.
+
+
+ Haftung für Inhalte
+
+ Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf diesen Seiten nach den
+ allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch
+ nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach
+ Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
+
+
+ Haftung für Links
+
+ Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss
+ haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die
+ Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
+ verantwortlich.
+
+
+ Urheberrecht
+
+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
+ deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
+ Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
+ jeweiligen Autors bzw. Erstellers.
+
+
diff --git a/apps/uload/apps/landing/src/pages/index.astro b/apps/uload/apps/landing/src/pages/index.astro
new file mode 100644
index 000000000..c8f1b6626
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/index.astro
@@ -0,0 +1,235 @@
+---
+import BaseLayout from '../layouts/BaseLayout.astro';
+import HeroSection from '../components/HeroSection.astro';
+
+// Shared components
+import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
+import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
+import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
+import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
+import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
+
+const appUrl = 'https://app.ulo.ad';
+
+// Feature data
+const features = [
+ {
+ icon: '🔗',
+ title: 'Smart Links',
+ description:
+ 'Kurze URLs mit Tracking, Ablaufdatum, Passwortschutz und UTM-Parametern für professionelles Marketing.',
+ },
+ {
+ icon: '📊',
+ title: 'Detaillierte Analytics',
+ description:
+ 'Verfolge Klicks, geografische Herkunft, Geräte und Referrer in Echtzeit mit übersichtlichen Dashboards.',
+ },
+ {
+ icon: '🎨',
+ title: 'QR-Code Generator',
+ description:
+ 'Erstelle anpassbare QR-Codes in verschiedenen Farben, Formen und mit deinem Logo für jeden Link.',
+ },
+ {
+ icon: '💳',
+ title: 'Profile Cards',
+ description:
+ 'Beeindruckende Profilseiten mit Drag & Drop Builder - deine digitale Visitenkarte.',
+ },
+ {
+ icon: '👥',
+ title: 'Team Workspaces',
+ description:
+ 'Arbeite im Team zusammen mit gemeinsamen Workspaces, Ordnern und granularen Berechtigungen.',
+ },
+ {
+ icon: '🔌',
+ title: 'API & Integrationen',
+ description:
+ 'RESTful API für automatisierte Workflows und Integration in deine bestehenden Tools.',
+ },
+];
+
+// Steps data
+const steps = [
+ {
+ number: '1',
+ title: 'Link einfügen',
+ description: 'Füge deine lange URL ein - egal ob Website, Social Media Post oder Dokument.',
+ image: '/screenshots/paste.png',
+ },
+ {
+ number: '2',
+ title: 'Anpassen',
+ description: 'Wähle einen Custom Slug, setze Ablaufdatum, Passwort oder UTM-Parameter.',
+ image: '/screenshots/customize.png',
+ },
+ {
+ number: '3',
+ title: 'Teilen & Tracken',
+ description: 'Teile deinen kurzen Link und verfolge alle Klicks in Echtzeit.',
+ image: '/screenshots/share.png',
+ },
+];
+
+// Pricing data
+const pricingPlans = [
+ {
+ name: 'Free',
+ price: '0',
+ period: '/Monat',
+ description: 'Perfekt zum Ausprobieren',
+ features: [
+ { text: '10 Links pro Monat', included: true },
+ { text: 'Basis Analytics', included: true },
+ { text: 'QR-Code Generator', included: true },
+ { text: 'Link Anpassung', included: true },
+ { text: 'Unbegrenzte Links', included: false },
+ { text: 'Team Features', included: false },
+ ],
+ cta: {
+ text: 'Kostenlos starten',
+ href: `${appUrl}/register`,
+ },
+ },
+ {
+ name: 'Pro',
+ price: '4,99',
+ period: '/Monat',
+ description: 'Für Freelancer & Creators',
+ features: [
+ { text: 'Unbegrenzte Links', included: true },
+ { text: 'Erweiterte Analytics', included: true },
+ { text: 'Custom QR Codes', included: true },
+ { text: 'API Zugang', included: true },
+ { text: 'Priority Support', included: true },
+ { text: 'Passwortschutz', included: true },
+ ],
+ cta: {
+ text: 'Pro wählen',
+ href: `${appUrl}/register?plan=pro`,
+ },
+ },
+ {
+ name: 'Pro Jährlich',
+ price: '3,33',
+ period: '/Monat',
+ description: 'Spare 20€ pro Jahr',
+ features: [
+ { text: 'Alle Pro Features', included: true },
+ { text: 'Unbegrenzte Links', included: true },
+ { text: 'Erweiterte Analytics', included: true },
+ { text: 'Custom QR Codes', included: true },
+ { text: 'API Zugang', included: true },
+ { text: 'Priority Support', included: true },
+ ],
+ cta: {
+ text: 'Jährlich sparen',
+ href: `${appUrl}/register?plan=pro-yearly`,
+ },
+ highlighted: true,
+ badge: 'Spare 20€',
+ },
+ {
+ name: 'Lifetime',
+ price: '129,99',
+ period: 'einmalig',
+ description: 'Einmal zahlen, für immer nutzen',
+ features: [
+ { text: 'Alle Pro Features', included: true },
+ { text: 'Lebenslanger Zugang', included: true },
+ { text: 'Alle zukünftigen Features', included: true },
+ { text: 'Early Access', included: true },
+ { text: 'Priority Support', included: true },
+ { text: 'Keine Abo-Gebühren', included: true },
+ ],
+ cta: {
+ text: 'Lifetime sichern',
+ href: `${appUrl}/register?plan=lifetime`,
+ },
+ badge: 'Einmalig',
+ },
+];
+
+// FAQ data
+const faqs = [
+ {
+ question: 'Wie lange bleiben meine Links aktiv?',
+ answer:
+ 'Im Free-Plan bleiben Links 1 Jahr aktiv. Mit Pro sind alle Links unbegrenzt gültig - es sei denn, du setzt selbst ein Ablaufdatum.',
+ },
+ {
+ question: 'Kann ich meine eigene Domain verwenden?',
+ answer:
+ 'Ja! Mit Pro kannst du deine eigene Domain verbinden und branded Short-Links erstellen (z.B. links.deinefirma.de/kampagne).',
+ },
+ {
+ question: 'Wie funktionieren die Analytics?',
+ answer:
+ 'Wir tracken Klicks, Herkunftsland, Gerät, Browser und Referrer - DSGVO-konform ohne Cookies. Du siehst alle Daten in Echtzeit im Dashboard.',
+ },
+ {
+ question: 'Was sind Profile Cards?',
+ answer:
+ 'Profile Cards sind customizable Landing Pages für deine Links. Perfekt für Bio-Links, digitale Visitenkarten oder Link-in-Bio für Social Media.',
+ },
+ {
+ question: 'Gibt es eine API?',
+ answer:
+ 'Ja! Mit Pro erhältst du vollen API-Zugang. Erstelle Links, rufe Analytics ab und integriere uLoad in deine Workflows programmatisch.',
+ },
+ {
+ question: 'Kann ich mein Abo jederzeit kündigen?',
+ answer:
+ 'Ja, du kannst monatliche Abos jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Pro-Features.',
+ },
+];
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/landing/src/pages/sicherheit.astro b/apps/uload/apps/landing/src/pages/sicherheit.astro
new file mode 100644
index 000000000..1b378e151
--- /dev/null
+++ b/apps/uload/apps/landing/src/pages/sicherheit.astro
@@ -0,0 +1,202 @@
+---
+import LegalLayout from '../layouts/LegalLayout.astro';
+---
+
+
+
+
Ihre Sicherheit ist unsere Priorität
+
+ Bei uload setzen wir modernste Sicherheitsstandards ein, um Ihre Daten und Links zu schützen.
+
+
+
+ Verschlüsselung
+
+ SSL/TLS-Verschlüsselung
+
+ Alle Datenübertragungen zwischen Ihrem Browser und unseren Servern sind durch moderne
+ SSL/TLS-Verschlüsselung geschützt. Wir verwenden ausschließlich TLS 1.3 und TLS 1.2 mit starken
+ Cipher-Suites.
+
+
+ Verschlüsselte Speicherung
+
+ Sensible Daten wie Passwörter werden mit branchenführenden Verschlüsselungsalgorithmen (bcrypt
+ mit Salt) gespeichert. Selbst im unwahrscheinlichen Fall eines Datenlecks bleiben Ihre
+ Passwörter geschützt.
+
+
+ Ende-zu-Ende Verschlüsselung für Premium-Nutzer
+
+ Premium-Nutzer können optionale Ende-zu-Ende-Verschlüsselung für besonders sensible Links
+ aktivieren. Diese Links können nur mit dem richtigen Schlüssel entschlüsselt werden.
+
+
+ Authentifizierung & Zugriffskontrolle
+
+ Sichere Authentifizierung
+
+ Starke Passwort-Anforderungen (mindestens 8 Zeichen, Groß-/Kleinbuchstaben, Zahlen)
+ Zwei-Faktor-Authentifizierung (2FA) verfügbar
+ Automatische Sitzungsbeendigung nach Inaktivität
+ Schutz vor Brute-Force-Angriffen durch Rate-Limiting
+
+
+ Passwortgeschützte Links
+
+ Erstellen Sie passwortgeschützte Links für zusätzliche Sicherheit. Nur Personen mit dem
+ korrekten Passwort können auf die Ziel-URL zugreifen.
+
+
+ IP-Whitelisting für Enterprise
+
+ Enterprise-Kunden können IP-Whitelisting aktivieren, um den Zugriff auf ihre Links nur von
+ bestimmten IP-Adressen oder IP-Bereichen zu erlauben.
+
+
+ Infrastruktur-Sicherheit
+
+ Hosting & Server
+
+ Hosting in ISO 27001 zertifizierten Rechenzentren
+ Redundante Server-Architektur für maximale Verfügbarkeit
+ Regelmäßige Sicherheitsupdates und Patches
+ 24/7 Überwachung der Systemintegrität
+
+
+ DDoS-Schutz
+
+ Unser Service ist durch einen fortschrittlichen DDoS-Schutz abgesichert, der Angriffe
+ automatisch erkennt und abwehrt, um die Verfügbarkeit unseres Dienstes zu gewährleisten.
+
+
+ Web Application Firewall (WAF)
+
+ Eine Web Application Firewall schützt vor gängigen Web-Angriffen wie SQL-Injection,
+ Cross-Site-Scripting (XSS) und anderen OWASP Top 10 Bedrohungen.
+
+
+ Überwachung & Schutz
+
+ Malware & Phishing-Schutz
+
+ Alle erstellten Links werden automatisch gegen bekannte Malware- und Phishing-Datenbanken
+ geprüft. Verdächtige Links werden blockiert und zur manuellen Überprüfung markiert.
+
+
+ Echtzeit-Überwachung
+
+ Kontinuierliche Überwachung auf verdächtige Aktivitäten
+ Automatische Erkennung von Missbrauchsmustern
+ Sofortige Benachrichtigung bei Sicherheitsvorfällen
+ Detaillierte Audit-Logs für Enterprise-Kunden
+
+
+ Link-Validierung
+
+ Regelmäßige Überprüfung aller Ziel-URLs auf Verfügbarkeit und Sicherheit. Gefährliche oder
+ kompromittierte Websites werden automatisch blockiert.
+
+
+ Datenschutz & Compliance
+
+ DSGVO-Konformität
+
+ Vollständige Einhaltung der Datenschutz-Grundverordnung (DSGVO). Sie haben jederzeit die volle
+ Kontrolle über Ihre Daten mit Rechten auf Auskunft, Berichtigung und Löschung.
+
+
+ Datensparsamkeit
+
+ Wir sammeln nur die minimal notwendigen Daten für den Betrieb unseres Services. Keine unnötige
+ Datensammlung oder -weitergabe an Dritte.
+
+
+ Regelmäßige Audits
+
+ Unabhängige Sicherheitsaudits und Penetrationstests werden regelmäßig durchgeführt, um höchste
+ Sicherheitsstandards zu gewährleisten.
+
+
+ Backup & Wiederherstellung
+
+ Automatische Backups
+
+ Tägliche automatische Backups aller Daten
+ Geografisch verteilte Backup-Speicherung
+ Verschlüsselte Backup-Archive
+ Regelmäßige Wiederherstellungstests
+
+
+ Disaster Recovery
+
+ Umfassender Disaster-Recovery-Plan mit RPO (Recovery Point Objective) von maximal 24 Stunden und
+ RTO (Recovery Time Objective) von maximal 4 Stunden.
+
+
+ Ihre Verantwortung
+
+ Best Practices für Nutzer
+
+ Verwenden Sie starke, einzigartige Passwörter
+ Aktivieren Sie die Zwei-Faktor-Authentifizierung
+ Teilen Sie Ihre Zugangsdaten niemals mit anderen
+ Melden Sie verdächtige Aktivitäten sofort
+ Halten Sie Ihre Kontaktinformationen aktuell
+ Überprüfen Sie regelmäßig Ihre Account-Aktivitäten
+
+
+ Sicherheitsvorfälle melden
+
+ Verantwortungsvolle Offenlegung
+
+ Wir schätzen die Arbeit von Sicherheitsforschern. Wenn Sie eine Sicherheitslücke entdecken,
+ melden Sie diese bitte verantwortungsvoll an:
+
+ security@uload.de
+
+ Bitte geben Sie uns angemessene Zeit zur Behebung, bevor Sie die Schwachstelle öffentlich
+ machen.
+
+
+ Bug Bounty Programm
+
+ Für kritische Sicherheitslücken bieten wir Belohnungen im Rahmen unseres Bug Bounty Programms.
+
+
+ Zertifizierungen & Standards
+
+
+
+
ISO 27001
+
Informationssicherheits-Management-System zertifiziert
+
+
+
SSL Labs A+
+
Höchste Bewertung für SSL/TLS-Konfiguration
+
+
+
OWASP Compliance
+
Einhaltung der OWASP-Sicherheitsrichtlinien
+
+
+
PCI DSS Ready
+
Bereit für Payment Card Industry Standards
+
+
+
+ Kontakt
+ Bei Fragen zur Sicherheit unseres Services kontaktieren Sie uns:
+
+ E-Mail: security@uload.de
+ PGP-Schlüssel: Verfügbar auf Anfrage
+
+
+
+
Tipp:
+
+ Aktivieren Sie die Zwei-Faktor-Authentifizierung in Ihren Account-Einstellungen für maximale
+ Sicherheit!
+
+
+
diff --git a/apps/uload/apps/landing/src/styles/global.css b/apps/uload/apps/landing/src/styles/global.css
new file mode 100644
index 000000000..37eeaf633
--- /dev/null
+++ b/apps/uload/apps/landing/src/styles/global.css
@@ -0,0 +1,92 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* uLoad Theme CSS Variables - Professional Blue (Light Theme) */
+:root {
+ /* Primary colors - uLoad Blue */
+ --color-primary: #3b82f6;
+ --color-primary-hover: #2563eb;
+ --color-primary-glow: rgba(59, 130, 246, 0.2);
+
+ /* Text colors (Light theme) */
+ --color-text-primary: #111827;
+ --color-text-secondary: #4b5563;
+ --color-text-muted: #6b7280;
+
+ /* Background colors (Light theme) */
+ --color-background-page: #ffffff;
+ --color-background-card: #f9fafb;
+ --color-background-card-hover: #f3f4f6;
+
+ /* Border colors */
+ --color-border: #e5e7eb;
+ --color-border-hover: #d1d5db;
+}
+
+/* Base styles */
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: 'Inter', system-ui, sans-serif;
+ background-color: var(--color-background-page);
+ color: var(--color-text-primary);
+ line-height: 1.6;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Selection */
+::selection {
+ background-color: var(--color-primary);
+ color: white;
+}
+
+/* Focus styles */
+:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+/* Gradient text */
+.text-gradient {
+ background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+/* Animation utilities */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.animate-fadeIn {
+ animation: fadeIn 0.6s ease-out forwards;
+}
+
+@layer components {
+ .btn-primary {
+ @apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors duration-200 shadow-lg hover:shadow-xl;
+ }
+
+ .btn-secondary {
+ @apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border-2 border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all duration-200;
+ }
+
+ .container-custom {
+ @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
+ }
+
+ .section {
+ @apply py-16 md:py-24;
+ }
+}
diff --git a/apps/uload/apps/landing/tailwind.config.mjs b/apps/uload/apps/landing/tailwind.config.mjs
new file mode 100644
index 000000000..d77f724cf
--- /dev/null
+++ b/apps/uload/apps/landing/tailwind.config.mjs
@@ -0,0 +1,48 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
+ '../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}',
+ ],
+ theme: {
+ extend: {
+ colors: {
+ // uLoad Professional Blue Theme (Light)
+ primary: {
+ 50: '#eff6ff',
+ 100: '#dbeafe',
+ 200: '#bfdbfe',
+ 300: '#93c5fd',
+ 400: '#60a5fa',
+ 500: '#3b82f6',
+ 600: '#2563eb',
+ 700: '#1d4ed8',
+ 800: '#1e40af',
+ 900: '#1e3a8a',
+ 950: '#172554',
+ DEFAULT: '#3b82f6',
+ hover: '#2563eb',
+ glow: 'rgba(59, 130, 246, 0.2)',
+ },
+ background: {
+ page: '#ffffff',
+ card: '#f9fafb',
+ 'card-hover': '#f3f4f6',
+ },
+ text: {
+ primary: '#111827',
+ secondary: '#4b5563',
+ muted: '#6b7280',
+ },
+ border: {
+ DEFAULT: '#e5e7eb',
+ hover: '#d1d5db',
+ },
+ },
+ fontFamily: {
+ sans: ['Inter', 'system-ui', 'sans-serif'],
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/apps/uload/apps/landing/tsconfig.json b/apps/uload/apps/landing/tsconfig.json
new file mode 100644
index 000000000..db479e67c
--- /dev/null
+++ b/apps/uload/apps/landing/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "@components/*": ["src/components/*"],
+ "@layouts/*": ["src/layouts/*"]
+ }
+ }
+}
diff --git a/apps/uload/apps/server/package.json b/apps/uload/apps/server/package.json
new file mode 100644
index 000000000..024400244
--- /dev/null
+++ b/apps/uload/apps/server/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@uload/server",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "bun run --watch src/index.ts",
+ "start": "bun run src/index.ts",
+ "type-check": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@manacore/uload-database": "workspace:*",
+ "drizzle-orm": "^0.44.7",
+ "hono": "^4.7.0",
+ "jose": "^6.1.2",
+ "postgres": "^3.4.7"
+ },
+ "devDependencies": {
+ "@types/bun": "^1.2.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/apps/uload/apps/server/src/config.ts b/apps/uload/apps/server/src/config.ts
new file mode 100644
index 000000000..af5e49ede
--- /dev/null
+++ b/apps/uload/apps/server/src/config.ts
@@ -0,0 +1,26 @@
+export interface Config {
+ port: number;
+ databaseUrl: string;
+ manaAuthUrl: string;
+ cors: { origins: string[] };
+}
+
+export function loadConfig(): Config {
+ const requiredEnv = (key: string, fallback?: string): string => {
+ const value = process.env[key] || fallback;
+ if (!value) throw new Error(`Missing required env var: ${key}`);
+ return value;
+ };
+
+ return {
+ port: parseInt(process.env.PORT || '3070', 10),
+ databaseUrl: requiredEnv(
+ 'DATABASE_URL',
+ 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev'
+ ),
+ manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
+ cors: {
+ origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
+ },
+ };
+}
diff --git a/apps/uload/apps/server/src/db/connection.ts b/apps/uload/apps/server/src/db/connection.ts
new file mode 100644
index 000000000..f21ce1a1c
--- /dev/null
+++ b/apps/uload/apps/server/src/db/connection.ts
@@ -0,0 +1,15 @@
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import * as schema from '@manacore/uload-database';
+
+let db: ReturnType> | null = null;
+
+export function getDb(databaseUrl: string) {
+ if (!db) {
+ const client = postgres(databaseUrl, { max: 10 });
+ db = drizzle(client, { schema });
+ }
+ return db;
+}
+
+export type Database = ReturnType;
diff --git a/apps/uload/apps/server/src/index.ts b/apps/uload/apps/server/src/index.ts
new file mode 100644
index 000000000..3b17f5b3d
--- /dev/null
+++ b/apps/uload/apps/server/src/index.ts
@@ -0,0 +1,39 @@
+import { Hono } from 'hono';
+import { cors } from 'hono/cors';
+import { loadConfig } from './config';
+import { getDb } from './db/connection';
+import { errorHandler } from './middleware/error-handler';
+import { jwtAuth } from './middleware/jwt-auth';
+import { RedirectService } from './services/redirect';
+import { AnalyticsService } from './services/analytics';
+import { healthRoutes } from './routes/health';
+import { createRedirectRoutes } from './routes/redirect';
+import { createAnalyticsRoutes } from './routes/analytics';
+
+const config = loadConfig();
+const db = getDb(config.databaseUrl);
+
+const redirectService = new RedirectService(db);
+const analyticsService = new AnalyticsService(db);
+
+const app = new Hono();
+
+app.onError(errorHandler);
+app.use('*', cors({ origin: config.cors.origins, credentials: true }));
+
+// Health (no auth)
+app.route('/health', healthRoutes);
+
+// Redirect (no auth — public)
+app.route('/r', createRedirectRoutes(redirectService));
+
+// Analytics API (auth required)
+app.use('/api/v1/*', jwtAuth(config.manaAuthUrl));
+app.route('/api/v1/analytics', createAnalyticsRoutes(analyticsService));
+
+console.log(`uload-server starting on port ${config.port}...`);
+
+export default {
+ port: config.port,
+ fetch: app.fetch,
+};
diff --git a/apps/uload/apps/server/src/lib/errors.ts b/apps/uload/apps/server/src/lib/errors.ts
new file mode 100644
index 000000000..a31444271
--- /dev/null
+++ b/apps/uload/apps/server/src/lib/errors.ts
@@ -0,0 +1,19 @@
+import { HTTPException } from 'hono/http-exception';
+
+export class NotFoundError extends HTTPException {
+ constructor(message = 'Not found') {
+ super(404, { message });
+ }
+}
+
+export class BadRequestError extends HTTPException {
+ constructor(message = 'Bad request') {
+ super(400, { message });
+ }
+}
+
+export class UnauthorizedError extends HTTPException {
+ constructor(message = 'Unauthorized') {
+ super(401, { message });
+ }
+}
diff --git a/apps/uload/apps/server/src/middleware/error-handler.ts b/apps/uload/apps/server/src/middleware/error-handler.ts
new file mode 100644
index 000000000..aec815e83
--- /dev/null
+++ b/apps/uload/apps/server/src/middleware/error-handler.ts
@@ -0,0 +1,11 @@
+import type { ErrorHandler } from 'hono';
+import { HTTPException } from 'hono/http-exception';
+
+export const errorHandler: ErrorHandler = (err, c) => {
+ if (err instanceof HTTPException) {
+ return c.json({ statusCode: err.status, message: err.message }, err.status);
+ }
+
+ console.error('Unhandled error:', err);
+ return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
+};
diff --git a/apps/uload/apps/server/src/middleware/jwt-auth.ts b/apps/uload/apps/server/src/middleware/jwt-auth.ts
new file mode 100644
index 000000000..570ac8bc7
--- /dev/null
+++ b/apps/uload/apps/server/src/middleware/jwt-auth.ts
@@ -0,0 +1,46 @@
+import type { MiddlewareHandler } from 'hono';
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+import { UnauthorizedError } from '../lib/errors';
+
+export interface AuthUser {
+ userId: string;
+ email: string;
+ role: string;
+}
+
+let jwks: ReturnType | null = null;
+
+function getJwks(authUrl: string) {
+ if (!jwks) {
+ jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
+ }
+ return jwks;
+}
+
+export function jwtAuth(authUrl: string): MiddlewareHandler {
+ return async (c, next) => {
+ const authHeader = c.req.header('Authorization');
+ if (!authHeader?.startsWith('Bearer ')) {
+ throw new UnauthorizedError('Missing or invalid Authorization header');
+ }
+
+ const token = authHeader.slice(7);
+ try {
+ const { payload } = await jwtVerify(token, getJwks(authUrl), {
+ issuer: authUrl,
+ audience: 'manacore',
+ });
+
+ const user: AuthUser = {
+ userId: payload.sub || '',
+ email: (payload.email as string) || '',
+ role: (payload.role as string) || 'user',
+ };
+
+ c.set('user', user);
+ await next();
+ } catch {
+ throw new UnauthorizedError('Invalid or expired token');
+ }
+ };
+}
diff --git a/apps/uload/apps/server/src/routes/analytics.ts b/apps/uload/apps/server/src/routes/analytics.ts
new file mode 100644
index 000000000..b81e84f47
--- /dev/null
+++ b/apps/uload/apps/server/src/routes/analytics.ts
@@ -0,0 +1,33 @@
+import { Hono } from 'hono';
+import type { AnalyticsService } from '../services/analytics';
+import type { AuthUser } from '../middleware/jwt-auth';
+
+export function createAnalyticsRoutes(analyticsService: AnalyticsService) {
+ return new Hono<{ Variables: { user: AuthUser } }>()
+ .get('/:linkId', async (c) => {
+ const linkId = c.req.param('linkId');
+ const stats = await analyticsService.getClickStats(linkId);
+ return c.json(stats);
+ })
+ .get('/:linkId/timeline', async (c) => {
+ const linkId = c.req.param('linkId');
+ const days = parseInt(c.req.query('days') || '30', 10);
+ const timeline = await analyticsService.getClicksOverTime(linkId, days);
+ return c.json(timeline);
+ })
+ .get('/:linkId/referrers', async (c) => {
+ const linkId = c.req.param('linkId');
+ const referrers = await analyticsService.getTopReferrers(linkId);
+ return c.json(referrers);
+ })
+ .get('/:linkId/devices', async (c) => {
+ const linkId = c.req.param('linkId');
+ const devices = await analyticsService.getDeviceBreakdown(linkId);
+ return c.json(devices);
+ })
+ .get('/:linkId/countries', async (c) => {
+ const linkId = c.req.param('linkId');
+ const countries = await analyticsService.getCountryBreakdown(linkId);
+ return c.json(countries);
+ });
+}
diff --git a/apps/uload/apps/server/src/routes/health.ts b/apps/uload/apps/server/src/routes/health.ts
new file mode 100644
index 000000000..1ca0b1b98
--- /dev/null
+++ b/apps/uload/apps/server/src/routes/health.ts
@@ -0,0 +1,10 @@
+import { Hono } from 'hono';
+
+export const healthRoutes = new Hono().get('/', (c) =>
+ c.json({
+ status: 'ok',
+ service: 'uload-server',
+ runtime: 'bun',
+ timestamp: new Date().toISOString(),
+ })
+);
diff --git a/apps/uload/apps/server/src/routes/redirect.ts b/apps/uload/apps/server/src/routes/redirect.ts
new file mode 100644
index 000000000..95d44d280
--- /dev/null
+++ b/apps/uload/apps/server/src/routes/redirect.ts
@@ -0,0 +1,74 @@
+import { Hono } from 'hono';
+import type { RedirectService } from '../services/redirect';
+import { NotFoundError } from '../lib/errors';
+
+export function createRedirectRoutes(redirectService: RedirectService) {
+ return new Hono().get('/:code', async (c) => {
+ const code = c.req.param('code');
+ const link = await redirectService.resolve(code);
+
+ if (!link) {
+ throw new NotFoundError('Link not found or expired');
+ }
+
+ // Track click asynchronously (don't block redirect)
+ const userAgent = c.req.header('User-Agent') || '';
+ const referer = c.req.header('Referer') || '';
+
+ // Simple UA parsing
+ const browser = parseBrowser(userAgent);
+ const deviceType = parseDeviceType(userAgent);
+ const os = parseOS(userAgent);
+
+ // Hash IP for privacy
+ const ip = c.req.header('X-Forwarded-For')?.split(',')[0]?.trim() || 'unknown';
+ const ipHash = await hashIP(ip);
+
+ redirectService
+ .trackClick(link.id, {
+ ipHash,
+ userAgent,
+ referer,
+ browser,
+ deviceType,
+ os,
+ })
+ .catch((err) => console.error('Click tracking error:', err));
+
+ return c.redirect(link.originalUrl, 302);
+ });
+}
+
+function parseDeviceType(ua: string): string {
+ if (/mobile|android|iphone|ipad/i.test(ua)) return 'mobile';
+ if (/tablet|ipad/i.test(ua)) return 'tablet';
+ return 'desktop';
+}
+
+function parseBrowser(ua: string): string {
+ if (/firefox/i.test(ua)) return 'Firefox';
+ if (/edg/i.test(ua)) return 'Edge';
+ if (/chrome|chromium/i.test(ua)) return 'Chrome';
+ if (/safari/i.test(ua)) return 'Safari';
+ if (/opera|opr/i.test(ua)) return 'Opera';
+ return 'Other';
+}
+
+function parseOS(ua: string): string {
+ if (/windows/i.test(ua)) return 'Windows';
+ if (/mac os/i.test(ua)) return 'macOS';
+ if (/linux/i.test(ua)) return 'Linux';
+ if (/android/i.test(ua)) return 'Android';
+ if (/iphone|ipad/i.test(ua)) return 'iOS';
+ return 'Other';
+}
+
+async function hashIP(ip: string): Promise {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(ip + 'uload-salt');
+ const hash = await crypto.subtle.digest('SHA-256', data);
+ return Array.from(new Uint8Array(hash))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+ .slice(0, 16);
+}
diff --git a/apps/uload/apps/server/src/services/analytics.ts b/apps/uload/apps/server/src/services/analytics.ts
new file mode 100644
index 000000000..42942582b
--- /dev/null
+++ b/apps/uload/apps/server/src/services/analytics.ts
@@ -0,0 +1,87 @@
+import { eq, sql, and, gte, lte, desc } from 'drizzle-orm';
+import { clicks } from '@manacore/uload-database';
+import type { Database } from '../db/connection';
+
+export class AnalyticsService {
+ constructor(private db: Database) {}
+
+ async getClicksByLink(linkId: string, from?: Date, to?: Date) {
+ const conditions = [eq(clicks.linkId, linkId)];
+ if (from) conditions.push(gte(clicks.clickedAt, from));
+ if (to) conditions.push(lte(clicks.clickedAt, to));
+
+ return this.db
+ .select()
+ .from(clicks)
+ .where(and(...conditions))
+ .orderBy(desc(clicks.clickedAt));
+ }
+
+ async getClickStats(linkId: string) {
+ const [stats] = await this.db
+ .select({
+ totalClicks: sql`count(*)`,
+ uniqueVisitors: sql`count(distinct ${clicks.ipHash})`,
+ browsers: sql>`json_object_agg(
+ coalesce(${clicks.browser}, 'unknown'),
+ 1
+ )`,
+ })
+ .from(clicks)
+ .where(eq(clicks.linkId, linkId));
+
+ return stats;
+ }
+
+ async getClicksOverTime(linkId: string, days = 30) {
+ const since = new Date();
+ since.setDate(since.getDate() - days);
+
+ return this.db
+ .select({
+ date: sql`date_trunc('day', ${clicks.clickedAt})::date`,
+ count: sql`count(*)`,
+ })
+ .from(clicks)
+ .where(and(eq(clicks.linkId, linkId), gte(clicks.clickedAt, since)))
+ .groupBy(sql`date_trunc('day', ${clicks.clickedAt})`)
+ .orderBy(sql`date_trunc('day', ${clicks.clickedAt})`);
+ }
+
+ async getTopReferrers(linkId: string, limit = 10) {
+ return this.db
+ .select({
+ referer: clicks.referer,
+ count: sql`count(*)`,
+ })
+ .from(clicks)
+ .where(eq(clicks.linkId, linkId))
+ .groupBy(clicks.referer)
+ .orderBy(desc(sql`count(*)`))
+ .limit(limit);
+ }
+
+ async getDeviceBreakdown(linkId: string) {
+ return this.db
+ .select({
+ deviceType: clicks.deviceType,
+ count: sql`count(*)`,
+ })
+ .from(clicks)
+ .where(eq(clicks.linkId, linkId))
+ .groupBy(clicks.deviceType)
+ .orderBy(desc(sql`count(*)`));
+ }
+
+ async getCountryBreakdown(linkId: string) {
+ return this.db
+ .select({
+ country: clicks.country,
+ count: sql`count(*)`,
+ })
+ .from(clicks)
+ .where(eq(clicks.linkId, linkId))
+ .groupBy(clicks.country)
+ .orderBy(desc(sql`count(*)`));
+ }
+}
diff --git a/apps/uload/apps/server/src/services/redirect.ts b/apps/uload/apps/server/src/services/redirect.ts
new file mode 100644
index 000000000..abff401df
--- /dev/null
+++ b/apps/uload/apps/server/src/services/redirect.ts
@@ -0,0 +1,60 @@
+import { eq, sql } from 'drizzle-orm';
+import { links, clicks } from '@manacore/uload-database';
+import type { Database } from '../db/connection';
+
+export class RedirectService {
+ constructor(private db: Database) {}
+
+ async resolve(shortCode: string) {
+ const [link] = await this.db
+ .select({
+ id: links.id,
+ originalUrl: links.originalUrl,
+ isActive: links.isActive,
+ password: links.password,
+ maxClicks: links.maxClicks,
+ clickCount: links.clickCount,
+ expiresAt: links.expiresAt,
+ })
+ .from(links)
+ .where(eq(links.shortCode, shortCode))
+ .limit(1);
+
+ if (!link) return null;
+ if (!link.isActive) return null;
+ if (link.expiresAt && new Date(link.expiresAt) < new Date()) return null;
+ if (link.maxClicks && link.clickCount && link.clickCount >= link.maxClicks) return null;
+
+ return link;
+ }
+
+ async trackClick(
+ linkId: string,
+ meta: {
+ ipHash?: string;
+ userAgent?: string;
+ referer?: string;
+ browser?: string;
+ deviceType?: string;
+ os?: string;
+ country?: string;
+ }
+ ) {
+ await Promise.all([
+ this.db.insert(clicks).values({
+ linkId,
+ ipHash: meta.ipHash,
+ userAgent: meta.userAgent,
+ referer: meta.referer,
+ browser: meta.browser,
+ deviceType: meta.deviceType,
+ os: meta.os,
+ country: meta.country,
+ }),
+ this.db
+ .update(links)
+ .set({ clickCount: sql`${links.clickCount} + 1` })
+ .where(eq(links.id, linkId)),
+ ]);
+ }
+}
diff --git a/apps/uload/apps/server/tsconfig.json b/apps/uload/apps/server/tsconfig.json
new file mode 100644
index 000000000..4f2959bb9
--- /dev/null
+++ b/apps/uload/apps/server/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "types": ["bun-types"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/uload/apps/web/.env.example b/apps/uload/apps/web/.env.example
new file mode 100644
index 000000000..2f7f0c241
--- /dev/null
+++ b/apps/uload/apps/web/.env.example
@@ -0,0 +1,36 @@
+# SvelteKit Configuration
+PORT=3000
+ORIGIN=https://your-domain.com
+NODE_ENV=production
+PUBLIC_APP_URL=https://ulo.ad
+
+# Database (PostgreSQL)
+# Development: Use local Docker container
+DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
+# Production: Use your Coolify/Hetzner PostgreSQL container
+# DATABASE_URL=postgresql://uload:your_password@uload-db-prod:5432/uload_prod
+
+# File Storage (Cloudflare R2)
+R2_ACCOUNT_ID=your_cloudflare_account_id
+R2_ACCESS_KEY_ID=your_r2_access_key
+R2_SECRET_ACCESS_KEY=your_r2_secret_key
+R2_BUCKET_AVATARS=uload-avatars
+R2_BUCKET_QR=uload-qr-codes
+R2_PUBLIC_URL=https://files.ulo.ad
+
+# Email (Resend)
+RESEND_API_KEY=re_your_resend_api_key
+RESEND_FROM_EMAIL=noreply@ulo.ad
+
+# Umami Analytics (optional)
+PUBLIC_UMAMI_URL=https://your-umami-instance.com
+PUBLIC_UMAMI_WEBSITE_ID=your-website-id
+
+# External Auth (to be implemented)
+# AUTH_PROVIDER_CLIENT_ID=
+# AUTH_PROVIDER_CLIENT_SECRET=
+
+# Coolify specific (if needed)
+# These will be set automatically by Coolify
+# COOLIFY_URL=
+# COOLIFY_TOKEN=
diff --git a/apps/uload/apps/web/.env.production.example b/apps/uload/apps/web/.env.production.example
new file mode 100644
index 000000000..697f30661
--- /dev/null
+++ b/apps/uload/apps/web/.env.production.example
@@ -0,0 +1,20 @@
+# SvelteKit Configuration
+NODE_ENV=production
+PORT=3000
+ORIGIN=https://your-domain.com
+PUBLIC_POCKETBASE_URL=https://your-domain.com/api
+
+# PocketBase Admin Credentials
+# These will be used to create the admin on first startup
+POCKETBASE_ADMIN_EMAIL=till.schneider@memoro.ai
+POCKETBASE_ADMIN_PASSWORD=p0ck3tRA1N
+
+# Umami Analytics
+# Replace with your actual Umami instance and website ID
+PUBLIC_UMAMI_URL=https://your-umami-instance.com
+PUBLIC_UMAMI_WEBSITE_ID=your-website-id
+
+# Optional: Additional Configuration
+# BODY_SIZE_LIMIT=512kb
+# PROTOCOL_HEADER=x-forwarded-proto
+# HOST_HEADER=x-forwarded-host
\ No newline at end of file
diff --git a/apps/uload/apps/web/.env.stripe.example b/apps/uload/apps/web/.env.stripe.example
new file mode 100644
index 000000000..e3682dd3e
--- /dev/null
+++ b/apps/uload/apps/web/.env.stripe.example
@@ -0,0 +1,17 @@
+# Stripe Configuration
+# Copy this to .env.local or add to your .env file
+
+# Stripe API Keys (get from https://dashboard.stripe.com/test/apikeys)
+PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_PUBLISHABLE_KEY_HERE
+STRIPE_SECRET_KEY=sk_test_YOUR_SECRET_KEY_HERE
+
+# Stripe Product & Price IDs (will be created automatically by Claude)
+STRIPE_PRODUCT_PRO=prod_xxx
+STRIPE_PRICE_MONTHLY=price_xxx
+STRIPE_PRICE_YEARLY=price_xxx
+
+# Stripe Webhook Secret (from webhook endpoint in dashboard)
+STRIPE_WEBHOOK_SECRET=whsec_xxx
+
+# App URL for redirects
+PUBLIC_APP_URL=http://localhost:5173 # Production: https://ulo.ad
\ No newline at end of file
diff --git a/apps/uload/apps/web/.npmrc b/apps/uload/apps/web/.npmrc
new file mode 100644
index 000000000..b6f27f135
--- /dev/null
+++ b/apps/uload/apps/web/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/apps/uload/apps/web/.prettierignore b/apps/uload/apps/web/.prettierignore
new file mode 100644
index 000000000..7d74fe246
--- /dev/null
+++ b/apps/uload/apps/web/.prettierignore
@@ -0,0 +1,9 @@
+# Package Managers
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
+bun.lock
+bun.lockb
+
+# Miscellaneous
+/static/
diff --git a/apps/uload/apps/web/.prettierrc b/apps/uload/apps/web/.prettierrc
new file mode 100644
index 000000000..8103a0b5d
--- /dev/null
+++ b/apps/uload/apps/web/.prettierrc
@@ -0,0 +1,16 @@
+{
+ "useTabs": true,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "printWidth": 100,
+ "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": {
+ "parser": "svelte"
+ }
+ }
+ ],
+ "tailwindStylesheet": "./src/app.css"
+}
diff --git a/apps/uload/apps/web/drizzle.config.ts b/apps/uload/apps/web/drizzle.config.ts
new file mode 100644
index 000000000..af619f226
--- /dev/null
+++ b/apps/uload/apps/web/drizzle.config.ts
@@ -0,0 +1,14 @@
+import type { Config } from 'drizzle-kit';
+
+export default {
+ schema: './src/lib/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url:
+ process.env.DATABASE_URL ||
+ 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev',
+ },
+ verbose: true,
+ strict: true,
+} satisfies Config;
diff --git a/apps/uload/apps/web/drizzle/0000_material_puma.sql b/apps/uload/apps/web/drizzle/0000_material_puma.sql
new file mode 100644
index 000000000..52957eaa5
--- /dev/null
+++ b/apps/uload/apps/web/drizzle/0000_material_puma.sql
@@ -0,0 +1,227 @@
+CREATE TABLE "accounts" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "owner" uuid NOT NULL,
+ "is_active" boolean DEFAULT true,
+ "plan_type" text DEFAULT 'free',
+ "settings" jsonb,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "clicks" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "link_id" uuid NOT NULL,
+ "ip_hash" text,
+ "user_agent" text,
+ "referer" text,
+ "browser" text,
+ "device_type" text,
+ "os" text,
+ "country" text,
+ "city" text,
+ "clicked_at" timestamp DEFAULT now() NOT NULL,
+ "utm_source" text,
+ "utm_medium" text,
+ "utm_campaign" text,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "feature_requests" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "title" text NOT NULL,
+ "description" text NOT NULL,
+ "user_id" uuid NOT NULL,
+ "status" text DEFAULT 'pending',
+ "vote_count" integer DEFAULT 0,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "feature_votes" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "feature_request_id" uuid NOT NULL,
+ "user_id" uuid NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "folders" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "user_id" uuid NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "link_tags" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "link_id" uuid NOT NULL,
+ "tag_id" uuid NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "links" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "short_code" text NOT NULL,
+ "custom_code" text,
+ "original_url" text NOT NULL,
+ "title" text,
+ "description" text,
+ "user_id" uuid,
+ "is_active" boolean DEFAULT true,
+ "password" text,
+ "max_clicks" integer,
+ "expires_at" timestamp,
+ "click_count" integer DEFAULT 0,
+ "qr_code_url" text,
+ "tags" jsonb,
+ "utm_source" text,
+ "utm_medium" text,
+ "utm_campaign" text,
+ "account_owner" uuid,
+ "workspace_id" uuid,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "links_short_code_unique" UNIQUE("short_code")
+);
+--> statement-breakpoint
+CREATE TABLE "notifications" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "user_id" uuid NOT NULL,
+ "type" text NOT NULL,
+ "title" text NOT NULL,
+ "message" text NOT NULL,
+ "data" jsonb,
+ "read" boolean DEFAULT false,
+ "action_url" text,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "pending_invitations" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "email" text NOT NULL,
+ "token" text NOT NULL,
+ "owner" uuid NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "accepted_at" timestamp,
+ "accepted_by" uuid,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "pending_invitations_token_unique" UNIQUE("token")
+);
+--> statement-breakpoint
+CREATE TABLE "shared_access" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "owner" uuid NOT NULL,
+ "user_id" uuid,
+ "permissions" jsonb,
+ "invitation_status" text DEFAULT 'pending',
+ "accepted_at" timestamp,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "tags" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "slug" text NOT NULL,
+ "color" text,
+ "icon" text,
+ "is_public" boolean DEFAULT false,
+ "usage_count" integer DEFAULT 0,
+ "user_id" uuid,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "users" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "external_auth_id" text,
+ "email" text NOT NULL,
+ "username" text NOT NULL,
+ "name" text,
+ "avatar_url" text,
+ "bio" text,
+ "location" text,
+ "website" text,
+ "github" text,
+ "twitter" text,
+ "linkedin" text,
+ "instagram" text,
+ "public_profile" boolean DEFAULT false,
+ "show_click_stats" boolean DEFAULT true,
+ "email_notifications" boolean DEFAULT true,
+ "default_expiry" integer,
+ "profile_background" text,
+ "verified" boolean DEFAULT false,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "users_external_auth_id_unique" UNIQUE("external_auth_id"),
+ CONSTRAINT "users_email_unique" UNIQUE("email"),
+ CONSTRAINT "users_username_unique" UNIQUE("username")
+);
+--> statement-breakpoint
+CREATE TABLE "workspaces" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" text NOT NULL,
+ "slug" text NOT NULL,
+ "type" text NOT NULL,
+ "owner" uuid NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "workspaces_slug_unique" UNIQUE("slug")
+);
+--> statement-breakpoint
+ALTER TABLE "accounts" ADD CONSTRAINT "accounts_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "clicks" ADD CONSTRAINT "clicks_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "feature_requests" ADD CONSTRAINT "feature_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_feature_request_id_feature_requests_id_fk" FOREIGN KEY ("feature_request_id") REFERENCES "public"."feature_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "feature_votes" ADD CONSTRAINT "feature_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "link_tags" ADD CONSTRAINT "link_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "links" ADD CONSTRAINT "links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "links" ADD CONSTRAINT "links_account_owner_accounts_id_fk" FOREIGN KEY ("account_owner") REFERENCES "public"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "links" ADD CONSTRAINT "links_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "pending_invitations" ADD CONSTRAINT "pending_invitations_accepted_by_users_id_fk" FOREIGN KEY ("accepted_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "shared_access" ADD CONSTRAINT "shared_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "tags" ADD CONSTRAINT "tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_users_id_fk" FOREIGN KEY ("owner") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "accounts_owner_idx" ON "accounts" USING btree ("owner");--> statement-breakpoint
+CREATE INDEX "clicks_link_id_idx" ON "clicks" USING btree ("link_id");--> statement-breakpoint
+CREATE INDEX "clicks_clicked_at_idx" ON "clicks" USING btree ("clicked_at");--> statement-breakpoint
+CREATE INDEX "clicks_country_idx" ON "clicks" USING btree ("country");--> statement-breakpoint
+CREATE INDEX "feature_requests_user_id_idx" ON "feature_requests" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "feature_requests_status_idx" ON "feature_requests" USING btree ("status");--> statement-breakpoint
+CREATE INDEX "feature_requests_vote_count_idx" ON "feature_requests" USING btree ("vote_count");--> statement-breakpoint
+CREATE INDEX "feature_votes_feature_request_id_idx" ON "feature_votes" USING btree ("feature_request_id");--> statement-breakpoint
+CREATE INDEX "feature_votes_user_id_idx" ON "feature_votes" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "feature_votes_unique_idx" ON "feature_votes" USING btree ("feature_request_id","user_id");--> statement-breakpoint
+CREATE INDEX "folders_user_id_idx" ON "folders" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "link_tags_link_id_idx" ON "link_tags" USING btree ("link_id");--> statement-breakpoint
+CREATE INDEX "link_tags_tag_id_idx" ON "link_tags" USING btree ("tag_id");--> statement-breakpoint
+CREATE INDEX "link_tags_unique_idx" ON "link_tags" USING btree ("link_id","tag_id");--> statement-breakpoint
+CREATE INDEX "links_user_id_idx" ON "links" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "links_short_code_idx" ON "links" USING btree ("short_code");--> statement-breakpoint
+CREATE INDEX "links_workspace_id_idx" ON "links" USING btree ("workspace_id");--> statement-breakpoint
+CREATE INDEX "links_account_owner_idx" ON "links" USING btree ("account_owner");--> statement-breakpoint
+CREATE INDEX "links_is_active_idx" ON "links" USING btree ("is_active");--> statement-breakpoint
+CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "notifications_read_idx" ON "notifications" USING btree ("read");--> statement-breakpoint
+CREATE INDEX "pending_invitations_email_idx" ON "pending_invitations" USING btree ("email");--> statement-breakpoint
+CREATE INDEX "pending_invitations_token_idx" ON "pending_invitations" USING btree ("token");--> statement-breakpoint
+CREATE INDEX "pending_invitations_owner_idx" ON "pending_invitations" USING btree ("owner");--> statement-breakpoint
+CREATE INDEX "shared_access_owner_idx" ON "shared_access" USING btree ("owner");--> statement-breakpoint
+CREATE INDEX "shared_access_user_id_idx" ON "shared_access" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "shared_access_status_idx" ON "shared_access" USING btree ("invitation_status");--> statement-breakpoint
+CREATE INDEX "tags_user_id_idx" ON "tags" USING btree ("user_id");--> statement-breakpoint
+CREATE INDEX "tags_slug_idx" ON "tags" USING btree ("slug");--> statement-breakpoint
+CREATE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
+CREATE INDEX "users_username_idx" ON "users" USING btree ("username");--> statement-breakpoint
+CREATE INDEX "users_external_auth_id_idx" ON "users" USING btree ("external_auth_id");--> statement-breakpoint
+CREATE INDEX "workspaces_slug_idx" ON "workspaces" USING btree ("slug");--> statement-breakpoint
+CREATE INDEX "workspaces_owner_idx" ON "workspaces" USING btree ("owner");
\ No newline at end of file
diff --git a/apps/uload/apps/web/drizzle/meta/0000_snapshot.json b/apps/uload/apps/web/drizzle/meta/0000_snapshot.json
new file mode 100644
index 000000000..b49bb3835
--- /dev/null
+++ b/apps/uload/apps/web/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,1762 @@
+{
+ "id": "2584c29f-f6ed-4eb7-a1d0-1940d6be47b9",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner": {
+ "name": "owner",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "plan_type": {
+ "name": "plan_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'free'"
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "accounts_owner_idx": {
+ "name": "accounts_owner_idx",
+ "columns": [
+ {
+ "expression": "owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_owner_users_id_fk": {
+ "name": "accounts_owner_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": ["owner"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.clicks": {
+ "name": "clicks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "link_id": {
+ "name": "link_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_hash": {
+ "name": "ip_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referer": {
+ "name": "referer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "browser": {
+ "name": "browser",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "os": {
+ "name": "os",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "city": {
+ "name": "city",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "clicked_at": {
+ "name": "clicked_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "clicks_link_id_idx": {
+ "name": "clicks_link_id_idx",
+ "columns": [
+ {
+ "expression": "link_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "clicks_clicked_at_idx": {
+ "name": "clicks_clicked_at_idx",
+ "columns": [
+ {
+ "expression": "clicked_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "clicks_country_idx": {
+ "name": "clicks_country_idx",
+ "columns": [
+ {
+ "expression": "country",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "clicks_link_id_links_id_fk": {
+ "name": "clicks_link_id_links_id_fk",
+ "tableFrom": "clicks",
+ "tableTo": "links",
+ "columnsFrom": ["link_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feature_requests": {
+ "name": "feature_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'pending'"
+ },
+ "vote_count": {
+ "name": "vote_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feature_requests_user_id_idx": {
+ "name": "feature_requests_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feature_requests_status_idx": {
+ "name": "feature_requests_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feature_requests_vote_count_idx": {
+ "name": "feature_requests_vote_count_idx",
+ "columns": [
+ {
+ "expression": "vote_count",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feature_requests_user_id_users_id_fk": {
+ "name": "feature_requests_user_id_users_id_fk",
+ "tableFrom": "feature_requests",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feature_votes": {
+ "name": "feature_votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "feature_request_id": {
+ "name": "feature_request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feature_votes_feature_request_id_idx": {
+ "name": "feature_votes_feature_request_id_idx",
+ "columns": [
+ {
+ "expression": "feature_request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feature_votes_user_id_idx": {
+ "name": "feature_votes_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feature_votes_unique_idx": {
+ "name": "feature_votes_unique_idx",
+ "columns": [
+ {
+ "expression": "feature_request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feature_votes_feature_request_id_feature_requests_id_fk": {
+ "name": "feature_votes_feature_request_id_feature_requests_id_fk",
+ "tableFrom": "feature_votes",
+ "tableTo": "feature_requests",
+ "columnsFrom": ["feature_request_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "feature_votes_user_id_users_id_fk": {
+ "name": "feature_votes_user_id_users_id_fk",
+ "tableFrom": "feature_votes",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.folders": {
+ "name": "folders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "folders_user_id_idx": {
+ "name": "folders_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "folders_user_id_users_id_fk": {
+ "name": "folders_user_id_users_id_fk",
+ "tableFrom": "folders",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.link_tags": {
+ "name": "link_tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "link_id": {
+ "name": "link_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "link_tags_link_id_idx": {
+ "name": "link_tags_link_id_idx",
+ "columns": [
+ {
+ "expression": "link_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "link_tags_tag_id_idx": {
+ "name": "link_tags_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "link_tags_unique_idx": {
+ "name": "link_tags_unique_idx",
+ "columns": [
+ {
+ "expression": "link_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "link_tags_link_id_links_id_fk": {
+ "name": "link_tags_link_id_links_id_fk",
+ "tableFrom": "link_tags",
+ "tableTo": "links",
+ "columnsFrom": ["link_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "link_tags_tag_id_tags_id_fk": {
+ "name": "link_tags_tag_id_tags_id_fk",
+ "tableFrom": "link_tags",
+ "tableTo": "tags",
+ "columnsFrom": ["tag_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.links": {
+ "name": "links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "short_code": {
+ "name": "short_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "custom_code": {
+ "name": "custom_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_url": {
+ "name": "original_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_clicks": {
+ "name": "max_clicks",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "click_count": {
+ "name": "click_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "qr_code_url": {
+ "name": "qr_code_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "account_owner": {
+ "name": "account_owner",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workspace_id": {
+ "name": "workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "links_user_id_idx": {
+ "name": "links_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "links_short_code_idx": {
+ "name": "links_short_code_idx",
+ "columns": [
+ {
+ "expression": "short_code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "links_workspace_id_idx": {
+ "name": "links_workspace_id_idx",
+ "columns": [
+ {
+ "expression": "workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "links_account_owner_idx": {
+ "name": "links_account_owner_idx",
+ "columns": [
+ {
+ "expression": "account_owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "links_is_active_idx": {
+ "name": "links_is_active_idx",
+ "columns": [
+ {
+ "expression": "is_active",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "links_user_id_users_id_fk": {
+ "name": "links_user_id_users_id_fk",
+ "tableFrom": "links",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "links_account_owner_accounts_id_fk": {
+ "name": "links_account_owner_accounts_id_fk",
+ "tableFrom": "links",
+ "tableTo": "accounts",
+ "columnsFrom": ["account_owner"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "links_workspace_id_workspaces_id_fk": {
+ "name": "links_workspace_id_workspaces_id_fk",
+ "tableFrom": "links",
+ "tableTo": "workspaces",
+ "columnsFrom": ["workspace_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "links_short_code_unique": {
+ "name": "links_short_code_unique",
+ "nullsNotDistinct": false,
+ "columns": ["short_code"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read": {
+ "name": "read",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "action_url": {
+ "name": "action_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_id_idx": {
+ "name": "notifications_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notifications_read_idx": {
+ "name": "notifications_read_idx",
+ "columns": [
+ {
+ "expression": "read",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pending_invitations": {
+ "name": "pending_invitations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner": {
+ "name": "owner",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accepted_at": {
+ "name": "accepted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accepted_by": {
+ "name": "accepted_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "pending_invitations_email_idx": {
+ "name": "pending_invitations_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pending_invitations_token_idx": {
+ "name": "pending_invitations_token_idx",
+ "columns": [
+ {
+ "expression": "token",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pending_invitations_owner_idx": {
+ "name": "pending_invitations_owner_idx",
+ "columns": [
+ {
+ "expression": "owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pending_invitations_owner_users_id_fk": {
+ "name": "pending_invitations_owner_users_id_fk",
+ "tableFrom": "pending_invitations",
+ "tableTo": "users",
+ "columnsFrom": ["owner"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "pending_invitations_accepted_by_users_id_fk": {
+ "name": "pending_invitations_accepted_by_users_id_fk",
+ "tableFrom": "pending_invitations",
+ "tableTo": "users",
+ "columnsFrom": ["accepted_by"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "pending_invitations_token_unique": {
+ "name": "pending_invitations_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.shared_access": {
+ "name": "shared_access",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner": {
+ "name": "owner",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "invitation_status": {
+ "name": "invitation_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'pending'"
+ },
+ "accepted_at": {
+ "name": "accepted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "shared_access_owner_idx": {
+ "name": "shared_access_owner_idx",
+ "columns": [
+ {
+ "expression": "owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "shared_access_user_id_idx": {
+ "name": "shared_access_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "shared_access_status_idx": {
+ "name": "shared_access_status_idx",
+ "columns": [
+ {
+ "expression": "invitation_status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "shared_access_owner_users_id_fk": {
+ "name": "shared_access_owner_users_id_fk",
+ "tableFrom": "shared_access",
+ "tableTo": "users",
+ "columnsFrom": ["owner"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "shared_access_user_id_users_id_fk": {
+ "name": "shared_access_user_id_users_id_fk",
+ "tableFrom": "shared_access",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "usage_count": {
+ "name": "usage_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tags_user_id_idx": {
+ "name": "tags_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tags_slug_idx": {
+ "name": "tags_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tags_user_id_users_id_fk": {
+ "name": "tags_user_id_users_id_fk",
+ "tableFrom": "tags",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "external_auth_id": {
+ "name": "external_auth_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website": {
+ "name": "website",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "github": {
+ "name": "github",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "twitter": {
+ "name": "twitter",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "linkedin": {
+ "name": "linkedin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instagram": {
+ "name": "instagram",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_profile": {
+ "name": "public_profile",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "show_click_stats": {
+ "name": "show_click_stats",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "email_notifications": {
+ "name": "email_notifications",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "default_expiry": {
+ "name": "default_expiry",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "profile_background": {
+ "name": "profile_background",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_email_idx": {
+ "name": "users_email_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_username_idx": {
+ "name": "users_username_idx",
+ "columns": [
+ {
+ "expression": "username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_external_auth_id_idx": {
+ "name": "users_external_auth_id_idx",
+ "columns": [
+ {
+ "expression": "external_auth_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_external_auth_id_unique": {
+ "name": "users_external_auth_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["external_auth_id"]
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ },
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": ["username"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspaces": {
+ "name": "workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner": {
+ "name": "owner",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspaces_slug_idx": {
+ "name": "workspaces_slug_idx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspaces_owner_idx": {
+ "name": "workspaces_owner_idx",
+ "columns": [
+ {
+ "expression": "owner",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspaces_owner_users_id_fk": {
+ "name": "workspaces_owner_users_id_fk",
+ "tableFrom": "workspaces",
+ "tableTo": "users",
+ "columnsFrom": ["owner"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "workspaces_slug_unique": {
+ "name": "workspaces_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": ["slug"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/apps/uload/apps/web/drizzle/meta/_journal.json b/apps/uload/apps/web/drizzle/meta/_journal.json
new file mode 100644
index 000000000..d3df524b9
--- /dev/null
+++ b/apps/uload/apps/web/drizzle/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1763571183375,
+ "tag": "0000_material_puma",
+ "breakpoints": true
+ }
+ ]
+}
diff --git a/apps/uload/apps/web/e2e/demo.test.ts b/apps/uload/apps/web/e2e/demo.test.ts
new file mode 100644
index 000000000..9985ce113
--- /dev/null
+++ b/apps/uload/apps/web/e2e/demo.test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from '@playwright/test';
+
+test('home page has expected h1', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.locator('h1')).toBeVisible();
+});
diff --git a/apps/uload/apps/web/eslint.config.js b/apps/uload/apps/web/eslint.config.js
new file mode 100644
index 000000000..86eff13dd
--- /dev/null
+++ b/apps/uload/apps/web/eslint.config.js
@@ -0,0 +1,40 @@
+import prettier from 'eslint-config-prettier';
+import { includeIgnoreFile } from '@eslint/compat';
+import js from '@eslint/js';
+import svelte from 'eslint-plugin-svelte';
+import globals from 'globals';
+import { fileURLToPath } from 'node:url';
+import ts from 'typescript-eslint';
+import svelteConfig from './svelte.config.js';
+
+const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
+
+export default ts.config(
+ includeIgnoreFile(gitignorePath),
+ js.configs.recommended,
+ ...ts.configs.recommended,
+ ...svelte.configs.recommended,
+ prettier,
+ ...svelte.configs.prettier,
+ {
+ languageOptions: {
+ globals: { ...globals.browser, ...globals.node },
+ },
+ rules: {
+ // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
+ // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
+ 'no-undef': 'off',
+ },
+ },
+ {
+ files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ extraFileExtensions: ['.svelte'],
+ parser: ts.parser,
+ svelteConfig,
+ },
+ },
+ }
+);
diff --git a/apps/uload/apps/web/package.json b/apps/uload/apps/web/package.json
new file mode 100644
index 000000000..7348c6075
--- /dev/null
+++ b/apps/uload/apps/web/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@uload/web",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "pnpm run test:unit && pnpm run test:e2e",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "format": "prettier --write .",
+ "lint": "prettier --check . && eslint .",
+ "test:unit": "vitest run",
+ "test:e2e": "playwright test",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "drizzle-kit migrate",
+ "db:push": "drizzle-kit push",
+ "db:studio": "drizzle-kit studio"
+ },
+ "type": "module",
+ "devDependencies": {
+ "@eslint/js": "^9.20.0",
+ "@playwright/test": "^1.51.0",
+ "@sveltejs/adapter-auto": "^4.0.0",
+ "@sveltejs/adapter-node": "^5.0.0",
+ "@sveltejs/kit": "^2.22.0",
+ "@sveltejs/vite-plugin-svelte": "^5.0.4",
+ "@tailwindcss/forms": "^0.5.8",
+ "@tailwindcss/typography": "^0.5.16",
+ "@tailwindcss/vite": "^4.1.11",
+ "@types/eslint__js": "^8.42.3",
+ "@types/node": "^24.3.0",
+ "@vitest/browser": "^3.2.4",
+ "@vitest/coverage-v8": "^3.2.4",
+ "drizzle-kit": "^0.31.7",
+ "eslint": "^9.20.0",
+ "eslint-config-prettier": "^10.0.1",
+ "eslint-plugin-svelte": "^2.35.0",
+ "globals": "^15.0.0",
+ "gray-matter": "^4.0.3",
+ "jsdom": "^26.1.0",
+ "mdsvex": "^0.12.6",
+ "playwright": "^1.51.0",
+ "prettier": "^3.4.2",
+ "prettier-plugin-svelte": "^3.4.0",
+ "prettier-plugin-tailwindcss": "^0.6.0",
+ "rehype-autolink-headings": "^7.1.0",
+ "rehype-slug": "^6.0.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.0.0",
+ "typescript-eslint": "^8.20.0",
+ "vite": "^7.0.4",
+ "vitest": "^3.2.3",
+ "vitest-browser-svelte": "^0.1.0",
+ "zod": "^4.0.17"
+ },
+ "dependencies": {
+ "@manacore/local-store": "workspace:*",
+ "@manacore/shared-auth-ui": "workspace:*",
+ "@manacore/shared-branding": "workspace:*",
+ "@manacore/shared-ui": "workspace:*",
+ "isomorphic-dompurify": "^2.26.0",
+ "lucide-svelte": "^0.539.0",
+ "svelte-i18n": "^4.0.1",
+ "svelte-sonner": "^1.0.5"
+ }
+}
diff --git a/apps/uload/apps/web/playwright.config.ts b/apps/uload/apps/web/playwright.config.ts
new file mode 100644
index 000000000..156389684
--- /dev/null
+++ b/apps/uload/apps/web/playwright.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ webServer: {
+ command: 'npm run build && npm run preview',
+ port: 4173,
+ },
+ testDir: 'e2e',
+});
diff --git a/apps/uload/apps/web/src/app.css b/apps/uload/apps/web/src/app.css
new file mode 100644
index 000000000..aab182880
--- /dev/null
+++ b/apps/uload/apps/web/src/app.css
@@ -0,0 +1,162 @@
+@import 'tailwindcss';
+@plugin '@tailwindcss/forms';
+@plugin '@tailwindcss/typography';
+
+/* Dark mode configuration */
+@variant dark (&:where(.dark, .dark *));
+
+/* Theme color utilities using CSS variables */
+@theme {
+ --color-theme-primary: var(--theme-primary);
+ --color-theme-primary-hover: var(--theme-primary-hover);
+ --color-theme-background: var(--theme-background);
+ --color-theme-surface: var(--theme-surface);
+ --color-theme-surface-hover: var(--theme-surface-hover);
+ --color-theme-text: var(--theme-text);
+ --color-theme-text-muted: var(--theme-text-muted);
+ --color-theme-border: var(--theme-border);
+ --color-theme-accent: var(--theme-accent);
+ --color-theme-accent-hover: var(--theme-accent-hover);
+}
+
+/* Theme CSS Variables - will be overridden by theme presets */
+:root {
+ --theme-primary: #171717;
+ --theme-primary-hover: #0a0a0a;
+ --theme-background: #fafafa;
+ --theme-surface: #ffffff;
+ --theme-surface-hover: #f5f5f5;
+ --theme-text: #171717;
+ --theme-text-muted: #737373;
+ --theme-border: #e5e5e5;
+ --theme-accent: #525252;
+ --theme-accent-hover: #404040;
+ --theme-font-family: Inter, system-ui, -apple-system, sans-serif;
+
+ /* Sonner Toast Styling - Light Mode */
+ --sonner-toast-gap: 8px;
+ --sonner-toast-padding: 16px;
+ --sonner-toast-border-radius: 12px;
+ --sonner-toast-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+}
+
+/* Apply theme font to body */
+body {
+ font-family: var(--theme-font-family);
+}
+
+/* Theme transition animation */
+.theme-transitioning,
+.theme-transitioning * {
+ transition:
+ background-color 0.3s ease,
+ color 0.3s ease,
+ border-color 0.3s ease,
+ fill 0.3s ease,
+ stroke 0.3s ease,
+ font-family 0.3s ease !important;
+}
+
+/* Ensure full viewport coverage */
+html,
+body {
+ @apply min-h-screen;
+}
+
+body {
+ font-family: var(--theme-font-family);
+ background-color: var(--theme-background);
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.animate-fade-in {
+ animation: fade-in 0.3s ease-out;
+}
+
+.animate-spin {
+ animation: spin 1s linear infinite;
+}
+
+/* Primary button class with proper contrast */
+.btn-primary {
+ @apply bg-theme-primary text-theme-background hover:bg-theme-primary-hover;
+}
+
+/* Sonner Toast Custom Styles */
+.sonner-toast {
+ font-family: var(--theme-font-family) !important;
+}
+
+.sonner-toast[data-type='success'] {
+ background-color: #10b981 !important;
+ color: white !important;
+ border: 1px solid #059669 !important;
+}
+
+.sonner-toast[data-type='error'] {
+ background-color: #ef4444 !important;
+ color: white !important;
+ border: 1px solid #dc2626 !important;
+}
+
+.sonner-toast[data-type='info'] {
+ background-color: #3b82f6 !important;
+ color: white !important;
+ border: 1px solid #2563eb !important;
+}
+
+.sonner-toast[data-type='warning'] {
+ background-color: #f59e0b !important;
+ color: white !important;
+ border: 1px solid #d97706 !important;
+}
+
+/* Dark mode toast styles */
+.dark .sonner-toast {
+ background-color: #374151 !important;
+ color: #f3f4f6 !important;
+ border: 1px solid #4b5563 !important;
+}
+
+.dark .sonner-toast[data-type='success'] {
+ background-color: #065f46 !important;
+ color: #d1fae5 !important;
+ border: 1px solid #10b981 !important;
+}
+
+.dark .sonner-toast[data-type='error'] {
+ background-color: #7f1d1d !important;
+ color: #fee2e2 !important;
+ border: 1px solid #ef4444 !important;
+}
+
+.dark .sonner-toast[data-type='info'] {
+ background-color: #1e3a8a !important;
+ color: #dbeafe !important;
+ border: 1px solid #3b82f6 !important;
+}
+
+.dark .sonner-toast[data-type='warning'] {
+ background-color: #78350f !important;
+ color: #fef3c7 !important;
+ border: 1px solid #f59e0b !important;
+}
diff --git a/apps/uload/apps/web/src/app.d.ts b/apps/uload/apps/web/src/app.d.ts
new file mode 100644
index 000000000..9611d02b6
--- /dev/null
+++ b/apps/uload/apps/web/src/app.d.ts
@@ -0,0 +1,15 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+
+declare global {
+ namespace App {
+ // interface Error {}
+ interface Locals {
+ locale: 'en' | 'de' | 'es' | 'fr' | 'it';
+ }
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/apps/uload/apps/web/src/app.html b/apps/uload/apps/web/src/app.html
new file mode 100644
index 000000000..115989ea4
--- /dev/null
+++ b/apps/uload/apps/web/src/app.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/uload/apps/web/src/content/authors/till-schneider.json b/apps/uload/apps/web/src/content/authors/till-schneider.json
new file mode 100644
index 000000000..1f2968dcd
--- /dev/null
+++ b/apps/uload/apps/web/src/content/authors/till-schneider.json
@@ -0,0 +1,11 @@
+{
+ "id": "till-schneider",
+ "name": "Till Schneider",
+ "bio": "Gründer von uload und begeistert von der Psychologie hinter digitalem Marketing.",
+ "avatar": "/images/authors/till.jpg",
+ "social": {
+ "twitter": "https://twitter.com/tillschneider",
+ "linkedin": "https://linkedin.com/in/tillschneider",
+ "website": "https://ulo.ad"
+ }
+}
diff --git a/apps/uload/apps/web/src/content/blog/link-tracking-guide.md b/apps/uload/apps/web/src/content/blog/link-tracking-guide.md
new file mode 100644
index 000000000..86e6f1628
--- /dev/null
+++ b/apps/uload/apps/web/src/content/blog/link-tracking-guide.md
@@ -0,0 +1,157 @@
+---
+title: Der ultimative Link-Tracking Guide für 2024
+excerpt: Erfahren Sie, wie Sie mit modernem Link-Tracking Ihre Marketing-Performance messbar verbessern und dabei DSGVO-konform bleiben.
+date: 2024-01-20
+author: till-schneider
+category: tutorial
+tags: [tracking, analytics, dsgvo, marketing]
+featured: false
+image: /blog/link-tracking.jpg
+---
+
+Link-Tracking ist der Schlüssel zu datengetriebenem Marketing. In diesem umfassenden Guide zeigen wir Ihnen, wie Sie Ihre Links professionell tracken, dabei datenschutzkonform bleiben und Ihre Conversion-Rate signifikant steigern.
+
+## Was ist Link-Tracking?
+
+Link-Tracking ermöglicht es Ihnen, das Verhalten Ihrer Nutzer zu verstehen:
+
+- Woher kommen Ihre Besucher?
+- Welche Kampagnen funktionieren?
+- Wie hoch ist Ihre Conversion-Rate?
+- Welche Inhalte performen am besten?
+
+## Die wichtigsten Metriken
+
+### 1. Click-Through-Rate (CTR)
+
+Die CTR zeigt, wie viele Personen Ihren Link gesehen und geklickt haben. Eine gute CTR liegt je nach Kanal zwischen 2-5%.
+
+### 2. Conversion Rate
+
+Der Prozentsatz der Klicks, die zu einer gewünschten Aktion führen (Kauf, Anmeldung, Download).
+
+### 3. Bounce Rate
+
+Wie viele Nutzer verlassen Ihre Seite sofort wieder? Eine hohe Bounce Rate deutet auf Probleme hin.
+
+### 4. Geographic Distribution
+
+Verstehen Sie, aus welchen Ländern und Regionen Ihre Besucher kommen.
+
+## UTM-Parameter richtig einsetzen
+
+UTM-Parameter sind der Standard für Campaign-Tracking:
+
+```
+https://ulo.ad/angebot
+?utm_source=newsletter
+&utm_medium=email
+&utm_campaign=winter-sale
+&utm_content=header-cta
+```
+
+### Die 5 UTM-Parameter
+
+1. **utm_source**: Woher kommt der Traffic? (newsletter, google, facebook)
+2. **utm_medium**: Welches Medium? (email, cpc, social)
+3. **utm_campaign**: Welche Kampagne? (winter-sale, black-friday)
+4. **utm_content**: Welcher spezifische Link? (header-cta, footer-link)
+5. **utm_term**: Welches Keyword? (bei Paid Search)
+
+## DSGVO-konformes Tracking
+
+### Was ist erlaubt?
+
+✅ **Anonymisierte Daten**
+
+- Gerätetyp
+- Browser
+- Ungefährer Standort (Land/Stadt)
+- Referrer
+
+✅ **Aggregierte Metriken**
+
+- Gesamtklicks
+- Durchschnittliche Verweildauer
+- Conversion-Raten
+
+### Was braucht Zustimmung?
+
+❌ **Personenbezogene Daten**
+
+- Vollständige IP-Adressen
+- Device Fingerprinting
+- Cross-Site Tracking
+- Retargeting-Pixel
+
+## Best Practices für Link-Tracking
+
+### 1. Konsistente Namenskonvention
+
+Entwickeln Sie ein einheitliches Schema:
+
+```
+utm_source: [channel]
+utm_medium: [type]
+utm_campaign: [yyyy-mm]-[campaign-name]
+```
+
+### 2. Dokumentation führen
+
+Erstellen Sie eine Tracking-Tabelle:
+| Kampagne | Source | Medium | Link | Erstellt |
+|----------|--------|--------|------|----------|
+| Winter Sale | newsletter | email | /winter | 2024-01-15 |
+
+### 3. Regelmäßige Bereinigung
+
+Löschen Sie alte, inaktive Links und konsolidieren Sie ähnliche Kampagnen.
+
+## A/B-Testing mit Links
+
+Testen Sie verschiedene Varianten:
+
+- Verschiedene Call-to-Actions
+- Unterschiedliche Landing Pages
+- Alternative Platzierungen
+- Timing-Experimente
+
+## Tools und Integration
+
+### Google Analytics 4
+
+- Automatisches UTM-Tracking
+- Conversion-Tracking
+- Audience-Segmentierung
+
+### Marketing-Automation
+
+- HubSpot
+- Mailchimp
+- ActiveCampaign
+
+### Social Media Tools
+
+- Buffer
+- Hootsuite
+- Sprout Social
+
+## Fehler, die Sie vermeiden sollten
+
+1. **Inkonsistente Parameter**: newsletter vs Newsletter vs Email-Newsletter
+2. **Zu viele Parameter**: Halten Sie es simpel
+3. **Keine Dokumentation**: Nach 6 Monaten weiß niemand mehr, was "camp-x1" war
+4. **Ignorieren der Daten**: Tracking ohne Analyse ist nutzlos
+
+## Zukunft des Link-Trackings
+
+- **Privacy-First**: Mehr Fokus auf aggregierte, anonyme Daten
+- **Server-Side Tracking**: Umgehung von Ad-Blockern
+- **KI-gestützte Analyse**: Automatische Mustererkennung
+- **Cross-Device Attribution**: Besseres Verständnis der Customer Journey
+
+## Fazit
+
+Professionelles Link-Tracking ist kein Nice-to-have, sondern ein Must-have für erfolgreiches digitales Marketing. Mit den richtigen Tools und Prozessen können Sie Ihre Marketing-Performance signifikant steigern und dabei vollständig DSGVO-konform bleiben.
+
+Starten Sie noch heute mit professionellem Link-Tracking – Ihre Conversion-Rate wird es Ihnen danken!
diff --git a/apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md b/apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md
new file mode 100644
index 000000000..ec75cc6e5
--- /dev/null
+++ b/apps/uload/apps/web/src/content/blog/psychologie-kurzer-urls.md
@@ -0,0 +1,184 @@
+---
+title: Die Psychologie kurzer URLs - Warum unser Gehirn sie liebt
+excerpt: 42% weniger Klicks bei langen URLs – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst. Erfahren Sie die Wissenschaft dahinter.
+date: 2024-01-15
+author: till-schneider
+category: psychology
+tags: [urls, psychology, conversion, marketing]
+featured: true
+image: /blog/psychology-urls.jpg
+seo:
+ title: URL-Psychologie Guide 2024 - Warum kurze Links funktionieren | uload Blog
+ description: Erfahren Sie, warum kurze URLs 42% mehr Klicks erhalten. Wissenschaftlich fundierte Erkenntnisse zur Cognitive Load Theory und praktische Tipps für bessere Conversion-Rates.
+---
+
+**42% weniger Klicks bei langen URLs** – diese erstaunliche Zahl zeigt, wie stark die Länge eines Links unsere Entscheidung beeinflusst, darauf zu klicken oder nicht. In diesem umfassenden Artikel tauchen wir tief in die Psychologie hinter kurzen URLs ein und zeigen Ihnen, wie Sie dieses Wissen für Ihren digitalen Erfolg nutzen können.
+
+## Das Problem mit langen URLs: Wenn Links Misstrauen erzeugen
+
+Stellen Sie sich vor: Fast die Hälfte Ihrer potenziellen Besucher klickt nicht auf Ihren Link – nur weil er zu lang ist. Was auf den ersten Blick wie eine technische Kleinigkeit erscheint, ist in Wahrheit ein psychologisches Phänomen mit enormen Auswirkungen auf Ihre Online-Performance.
+
+### Die Spam-Alarm-Reaktion unseres Gehirns
+
+Aktuelle Studien zeigen eindeutig: URLs, die länger als 100 Zeichen sind, lösen automatisch Misstrauen aus. Unser Gehirn hat über Jahre hinweg gelernt, dass lange, unleserliche Links mit unzähligen Parametern oft zu zweifelhaften Inhalten führen. Diese evolutionäre Schutzreaktion lässt uns instinktiv zurückschrecken.
+
+Vergleichen Sie diese beiden URLs:
+
+**Lange URL (schlecht):**
+
+```
+https://example.com/product?id=12345&utm_source=newsletter&utm_medium=email&utm_campaign=summer2024&ref=user789&tracking=enabled
+```
+
+**Kurze URL (gut):**
+
+```
+https://ulo.ad/summer-sale
+```
+
+Der Unterschied ist offensichtlich, oder?
+
+### Mobile Nutzer: Die vergessene Mehrheit
+
+In einer Welt, in der über 60% des Web-Traffics von mobilen Geräten kommt, sind lange URLs ein noch größeres Problem. Mobile Nutzer scrollen definitiv nicht horizontal, um einen Link vollständig zu sehen. Was nicht auf den ersten Blick erkennbar ist, wird ignoriert – eine simple, aber folgenreiche Wahrheit.
+
+## Die Wissenschaft dahinter: Cognitive Load Theory
+
+### Warum unser Gehirn faul ist (und das gut so ist)
+
+Die Cognitive Load Theory erklärt, warum kurze URLs so effektiv sind. Unser Gehirn ist darauf programmiert, Energie zu sparen – es ist evolutionär faul, aber auf eine intelligente Weise. Bei der Verarbeitung von Informationen sucht es immer nach dem Weg des geringsten Widerstands.
+
+Wenn wir einen kurzen, klaren Link sehen, kann unser Gehirn ihn schnell verarbeiten und kategorisieren. Diese mühelose Verarbeitung erzeugt ein positives Gefühl – wir verbinden "einfach" automatisch mit "sicher" und "vertrauenswürdig".
+
+### Der Halo-Effekt kurzer URLs
+
+Psychologen nennen es den Halo-Effekt: Ein positives Merkmal (die Kürze des Links) überträgt sich auf die gesamte Wahrnehmung. Ein kurzer, sauberer Link lässt uns unbewusst annehmen, dass auch die Zielseite professionell, sicher und relevant sein wird.
+
+## Die vier Säulen des Link-Vertrauens
+
+Unsere Analyse von über 10.000 Link-Klicks hat vier Hauptfaktoren identifiziert:
+
+### 1. Erkennbare Domain (60% Wichtigkeit)
+
+Menschen wollen wissen, wo sie landen werden. Eine klare, erkennbare Domain ist der wichtigste Vertrauensfaktor:
+
+- Verwenden Sie Ihre Marken-Domain wenn möglich
+- Bei Kurz-URLs: Wählen Sie einen Service mit gutem Ruf
+- Vermeiden Sie obskure URL-Shortener
+
+### 2. Keine kryptischen Zeichen (25% Wichtigkeit)
+
+Zufällige Zahlen-Buchstaben-Kombinationen wie "x7h9k2p" schrecken Nutzer ab. Stattdessen:
+
+- Nutzen Sie sprechende Begriffe
+- Verwenden Sie relevante Keywords
+- Halten Sie es lesbar und merkbar
+
+### 3. Optimale Länge (10% Wichtigkeit)
+
+Die magische Grenze liegt bei etwa 50 Zeichen:
+
+- **15-30 Zeichen**: Optimal für Social Media
+- **30-50 Zeichen**: Ideal für E-Mail-Marketing
+- **Über 50 Zeichen**: Deutlicher Rückgang der Klickrate
+
+### 4. HTTPS-Verschlüsselung (5% Wichtigkeit)
+
+Das kleine Schloss-Symbol mag nur 5% ausmachen, aber es ist ein Hygienefaktor – fehlt es, kann das Vertrauen komplett zerstört werden.
+
+## Praktische Optimierungsstrategien
+
+### 1. Sprechende URLs verwenden
+
+❌ **Schlecht:** `ulo.ad/p47829`
+✅ **Gut:** `ulo.ad/sommer-sale`
+
+Der Unterschied? Der zweite Link kommuniziert sofort, was den Nutzer erwartet. Diese Transparenz erhöht die Klickrate um durchschnittlich 39%.
+
+### 2. Die 50-Zeichen-Regel
+
+Halten Sie Ihre URLs unter 50 Zeichen. Das ist:
+
+- Kurz genug für Twitter/X
+- Lesbar auf Mobilgeräten
+- Merkbar für Nutzer
+- Optimal für die Anzeige in E-Mails
+
+### 3. A/B-Testing ist Ihr Freund
+
+Testen Sie verschiedene URL-Varianten:
+
+- Kurz vs. deskriptiv
+- Mit Markenname vs. ohne
+- Verschiedene Keywords
+- Unterschiedliche Strukturen
+
+### 4. Performance-Tracking implementieren
+
+Ohne Daten keine Optimierung. Moderne Link-Management-Tools bieten:
+
+- Detaillierte Klick-Statistiken
+- Geografische Verteilung
+- Geräteerkennung
+- Referrer-Tracking
+- Conversion-Tracking
+
+## Case Studies: Erfolgsgeschichten
+
+### E-Commerce: 67% mehr Conversions
+
+Ein großer Online-Händler verkürzte seine Produkt-URLs von durchschnittlich 120 auf 45 Zeichen:
+
+- **67% höhere Conversion Rate**
+- **42% mehr Social Shares**
+- **31% niedrigere Bounce Rate**
+
+### Newsletter-Marketing: Verdoppelte Klickrate
+
+Ein B2B-Unternehmen wechselte von langen Tracking-URLs zu personalisierten Kurz-URLs:
+
+- **Vorher:** `company.com/newsletter/2024/march/article-5?utm_source=email&utm_medium=newsletter`
+- **Nachher:** `co.link/cloud-guide`
+- **Resultat:** 2,1x höhere Klickrate
+
+## Die Zukunft kurzer URLs
+
+### KI-optimierte Personalisierung
+
+Moderne Systeme nutzen KI, um für jeden Nutzer die optimale URL-Variante zu generieren – basierend auf:
+
+- Demografischen Daten
+- Bisherigem Klickverhalten
+- Kontext der Interaktion
+- Tageszeit und Gerät
+
+### Voice-First Optimization
+
+Mit dem Aufstieg von Sprachassistenten werden "sprechbare" URLs wichtiger:
+
+- Einfache Wörter statt Buchstaben-Zahlen-Kombinationen
+- Vermeidung ähnlich klingender Begriffe
+- Klare, eindeutige Aussprache
+
+## Fazit: Die Macht der Kürze
+
+Die Psychologie kurzer URLs ist keine Raketenwissenschaft, aber ihre Auswirkungen sind enorm. In einer Welt, in der Aufmerksamkeit die wertvollste Währung ist, können kurze, vertrauenswürdige Links den Unterschied zwischen Erfolg und Misserfolg ausmachen.
+
+### Die wichtigsten Takeaways
+
+1. **42% weniger Klicks** bei URLs über 100 Zeichen
+2. **Cognitive Load Theory**: Unser Gehirn liebt Einfachheit
+3. **50 Zeichen** ist die magische Grenze
+4. **Sprechende URLs** performen 39% besser
+5. **Mobile First**: Über 60% surfen mobil
+6. **Vertrauen** ist wichtiger als Tracking
+
+### Ihre nächsten Schritte
+
+1. **Audit**: Analysieren Sie Ihre aktuellen URLs
+2. **Optimieren**: Kürzen und verbessern Sie systematisch
+3. **Testen**: A/B-Tests für verschiedene Varianten
+4. **Messen**: Tracking der Performance-Verbesserungen
+5. **Iterieren**: Kontinuierliche Optimierung basierend auf Daten
+
+Tools wie [uload](https://ulo.ad) wurden speziell entwickelt, um die Erkenntnisse der URL-Psychologie in die Praxis umzusetzen. Mit Features wie personalisierten Kurz-URLs, detaillierten Analytics und A/B-Testing können Sie sofort damit beginnen, Ihre Link-Performance zu optimieren.
diff --git a/apps/uload/apps/web/src/content/config.ts b/apps/uload/apps/web/src/content/config.ts
new file mode 100644
index 000000000..2d534a399
--- /dev/null
+++ b/apps/uload/apps/web/src/content/config.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod';
+
+// Author Schema
+export const authorSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ bio: z.string().optional(),
+ avatar: z.string().optional(),
+ social: z
+ .object({
+ twitter: z.string().optional(),
+ github: z.string().optional(),
+ linkedin: z.string().optional(),
+ website: z.string().optional(),
+ })
+ .optional(),
+});
+
+// Blog Post Schema
+export const blogSchema = z.object({
+ title: z.string(),
+ excerpt: z.string(),
+ date: z
+ .string()
+ .or(z.date())
+ .transform((val) => new Date(val)),
+ author: z.string(), // Author ID
+ tags: z.array(z.string()).default([]),
+ category: z.enum(['tutorial', 'psychology', 'feature', 'announcement', 'case-study']),
+ image: z.string().optional(),
+ draft: z.boolean().default(false),
+ featured: z.boolean().default(false),
+ series: z.string().optional(),
+ layout: z.string().default('blog'),
+ seo: z
+ .object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ canonical: z.string().optional(),
+ })
+ .optional(),
+});
+
+// Type exports
+export type BlogPost = z.infer;
+export type Author = z.infer;
+
+// Extended types with computed fields
+export interface BlogPostWithMeta extends BlogPost {
+ slug: string;
+ readingTime: number;
+ path?: string;
+}
+
+export interface BlogCategory {
+ name: string;
+ slug: string;
+ count: number;
+}
+
+export interface BlogTag {
+ name: string;
+ count: number;
+}
diff --git a/apps/uload/apps/web/src/demo.spec.ts b/apps/uload/apps/web/src/demo.spec.ts
new file mode 100644
index 000000000..e07cbbd72
--- /dev/null
+++ b/apps/uload/apps/web/src/demo.spec.ts
@@ -0,0 +1,7 @@
+import { describe, it, expect } from 'vitest';
+
+describe('sum test', () => {
+ it('adds 1 + 2 to equal 3', () => {
+ expect(1 + 2).toBe(3);
+ });
+});
diff --git a/apps/uload/apps/web/src/hooks.server.ts b/apps/uload/apps/web/src/hooks.server.ts
new file mode 100644
index 000000000..b687eacf0
--- /dev/null
+++ b/apps/uload/apps/web/src/hooks.server.ts
@@ -0,0 +1,6 @@
+import type { Handle } from '@sveltejs/kit';
+
+export const handle: Handle = async ({ event, resolve }) => {
+ const response = await resolve(event);
+ return response;
+};
diff --git a/apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte b/apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte
new file mode 100644
index 000000000..8f2c6ba63
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/ab-testing/components/HeroABTest.svelte
@@ -0,0 +1,251 @@
+
+
+{#if showDebug}
+
+
+
A/B Test Debug
+
Variant: {variant}
+
Name: {content.name}
+
Locale: {get(locale)}
+
+ {
+ hashManager.reset();
+ window.location.reload();
+ }}
+ class="rounded bg-red-600 px-2 py-1 text-xs hover:bg-red-700"
+ >
+ Reset & Reload
+
+
+
+
+{/if}
+
+
+
+
+
+
+
+ {#if !isLoading}
+
+
+
+ {#if variant === 'b2' && content.headline.includes(',')}
+
+ {content.headline.split(',')[0]},
+ {content.headline.split(',').slice(1).join(',')}
+ {:else}
+ {content.headline}
+ {/if}
+
+
+
+
+ {content.subheadline}
+
+
+
+ {#if content.socialProof}
+
+ {#if content.socialProof.type === 'numbers'}
+
+ {#each content.socialProof.content.split('•') as stat}
+
+ ✓
+ {stat.trim()}
+
+ {/each}
+
+ {:else if content.socialProof.type === 'logos'}
+
+ {#each content.socialProof.content.split('•') as logo}
+
+ {logo.trim()}
+
+ {/each}
+
+ {:else if content.socialProof.type === 'testimonial'}
+
+ {content.socialProof.content}
+
+ {/if}
+
+ {/if}
+
+
+ {#if content.features && content.features.length > 0}
+
+ {#each content.features.slice(0, 3) as feature}
+
+ {/each}
+ {#if content.features.length > 3}
+ {#each content.features.slice(3) as feature}
+
+ {/each}
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+ {#each trustBadges as badge}
+
+ {badge.icon}
+ {badge.text}
+
+ {/each}
+
+
+ {:else}
+
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/ab-testing/config/variants.ts b/apps/uload/apps/web/src/lib/ab-testing/config/variants.ts
new file mode 100644
index 000000000..5c8e1a933
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/ab-testing/config/variants.ts
@@ -0,0 +1,208 @@
+/**
+ * A/B Testing Variant Configurations
+ * Defines content and styling for each variant using multilingual messages
+ */
+
+import * as m from '$paraglide/messages';
+
+export interface VariantContent {
+ id: string;
+ name: string;
+ headline: string;
+ subheadline: string;
+ ctaText: string;
+ ctaStyle?: string;
+ features?: string[];
+ socialProof?: {
+ type: 'numbers' | 'logos' | 'testimonial';
+ content: string;
+ };
+ layout?: 'standard' | 'split' | 'centered';
+}
+
+// Get variant content with multilingual support
+export function getVariantContent(variantId: string): VariantContent {
+ switch (variantId) {
+ case 'control':
+ return {
+ id: 'control',
+ name: 'Control (Baseline)',
+ headline: m.hero_control_headline(),
+ subheadline: m.hero_control_subheadline(),
+ ctaText: m.hero_control_cta(),
+ ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
+ layout: 'standard',
+ };
+
+ // Variant A - Value Focused
+ case 'a1':
+ return {
+ id: 'a1',
+ name: 'Value Generic',
+ headline: m.hero_a1_headline(),
+ subheadline: m.hero_a1_subheadline(),
+ ctaText: m.hero_a1_cta(),
+ ctaStyle: 'bg-blue-600 hover:bg-blue-700',
+ features: [m.hero_a1_feature_1(), m.hero_a1_feature_2(), m.hero_a1_feature_3()],
+ layout: 'standard',
+ };
+
+ case 'a2':
+ return {
+ id: 'a2',
+ name: 'Value Specific',
+ headline: 'Save 3 Hours Per Week on Link Management',
+ subheadline: 'Join teams who reduced their link management tasks by 75%',
+ ctaText: 'Calculate Your Savings',
+ ctaStyle:
+ 'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700',
+ features: ['3 hours saved weekly', '75% faster workflows', 'ROI in 2 weeks'],
+ layout: 'standard',
+ };
+
+ case 'a3':
+ return {
+ id: 'a3',
+ name: 'Value Transform',
+ headline: 'Your Links, 10x More Powerful',
+ subheadline: 'Transform every URL into a conversion machine with analytics and automation',
+ ctaText: 'Unlock Link Power →',
+ ctaStyle: 'bg-black hover:bg-gray-800',
+ features: ['10x more clicks', 'Conversion tracking', 'Smart redirects'],
+ layout: 'centered',
+ };
+
+ // Variant B - Social Proof
+ case 'b1':
+ return {
+ id: 'b1',
+ name: 'Social Numbers',
+ headline: m.hero_b1_headline(),
+ subheadline: m.hero_b1_subheadline(),
+ ctaText: m.hero_b1_cta(),
+ ctaStyle: 'bg-purple-600 hover:bg-purple-700',
+ socialProof: {
+ type: 'numbers',
+ content: m.hero_b1_social(),
+ },
+ layout: 'standard',
+ };
+
+ case 'b2':
+ return {
+ id: 'b2',
+ name: 'Social Logos',
+ headline: 'Trusted by Google, Meta, and Microsoft Teams',
+ subheadline: 'Enterprise-grade URL management for companies of all sizes',
+ ctaText: 'See Why They Chose Us',
+ ctaStyle:
+ 'bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700',
+ socialProof: {
+ type: 'logos',
+ content: 'Google • Meta • Microsoft • Spotify • Netflix',
+ },
+ layout: 'standard',
+ };
+
+ case 'b3':
+ return {
+ id: 'b3',
+ name: 'Social Testimonial',
+ headline: 'Rated #1 URL Shortener by Marketing Teams',
+ subheadline: '"uLoad saved us 5 hours per week and increased our CTR by 40%"',
+ ctaText: 'Read Success Stories',
+ ctaStyle: 'bg-green-600 hover:bg-green-700',
+ socialProof: {
+ type: 'testimonial',
+ content: '⭐⭐⭐⭐⭐ 4.9/5 from 1,000+ reviews',
+ },
+ layout: 'centered',
+ };
+
+ // Variant C - Feature Focused
+ case 'c1':
+ return {
+ id: 'c1',
+ name: 'Features All-in-One',
+ headline: m.hero_c1_headline(),
+ subheadline: m.hero_c1_subheadline(),
+ ctaText: m.hero_c1_cta(),
+ ctaStyle: 'bg-indigo-600 hover:bg-indigo-700',
+ features: [
+ m.hero_c1_feature_1(),
+ m.hero_c1_feature_2(),
+ m.hero_c1_feature_3(),
+ m.hero_c1_feature_4(),
+ m.hero_c1_feature_5(),
+ m.hero_c1_feature_6(),
+ ],
+ layout: 'standard',
+ };
+
+ case 'c2':
+ return {
+ id: 'c2',
+ name: 'Features QR Focus',
+ headline: 'QR Codes That Actually Convert',
+ subheadline: 'Create dynamic QR codes with real-time analytics and custom branding',
+ ctaText: 'Create Your First QR Code',
+ ctaStyle: 'bg-orange-600 hover:bg-orange-700',
+ features: ['Dynamic QR codes', 'Custom designs', 'Scan analytics', 'Bulk generation'],
+ layout: 'split',
+ };
+
+ case 'c3':
+ return {
+ id: 'c3',
+ name: 'Features Integration',
+ headline: 'Works With Your Favorite Tools',
+ subheadline: 'Seamless integration with Zapier, Slack, WordPress & 100+ platforms',
+ ctaText: 'Connect Your Tools',
+ ctaStyle: 'bg-teal-600 hover:bg-teal-700',
+ features: [
+ 'Zapier automation',
+ 'Slack notifications',
+ 'WordPress plugin',
+ 'API & Webhooks',
+ ],
+ layout: 'standard',
+ };
+
+ // Default to control
+ default:
+ return {
+ id: 'control',
+ name: 'Control (Baseline)',
+ headline: m.hero_control_headline(),
+ subheadline: m.hero_control_subheadline(),
+ ctaText: m.hero_control_cta(),
+ ctaStyle: 'bg-theme-primary hover:bg-theme-primary-hover',
+ layout: 'standard',
+ };
+ }
+}
+
+// Get all active variant IDs
+export function getActiveVariantIds(): string[] {
+ return ['control', 'a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3'];
+}
+
+// Check if variant exists
+export function isValidVariant(variantId: string): boolean {
+ return getActiveVariantIds().includes(variantId);
+}
+
+// Get trust badges with translations
+export function getTrustBadges(): Array<{ icon: string; text: string }> {
+ return [
+ { icon: '🔒', text: m.hero_trust_badge_1() },
+ { icon: '🇪🇺', text: m.hero_trust_badge_2() },
+ { icon: '⚡', text: m.hero_trust_badge_3() },
+ { icon: '🚀', text: m.hero_trust_badge_4() },
+ ];
+}
+
+// Get free text
+export function getFreeText(): string {
+ return m.hero_free_text();
+}
diff --git a/apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts b/apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts
new file mode 100644
index 000000000..5bfd16e33
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/ab-testing/service/HashManager.ts
@@ -0,0 +1,209 @@
+/**
+ * Hash-based A/B Testing Manager
+ * Manages variant assignment and persistence via URL hash
+ */
+export class HashManager {
+ // Valid variants with versions
+ private readonly validVariants = ['a1', 'a2', 'a3', 'b1', 'b2', 'b3', 'c1', 'c2', 'c3'];
+
+ // Current traffic distribution (percentages must sum to 100)
+ private readonly distribution: Record = {
+ control: 40, // Baseline
+ a1: 20, // Value-focused variant
+ b1: 20, // Social proof variant
+ c1: 20, // Feature-focused variant
+ };
+
+ // Storage key for backup
+ private readonly storageKey = 'uload_ab_variant';
+
+ // Debug mode flag
+ private debugMode = false;
+
+ constructor() {
+ // Check for debug mode
+ if (typeof window !== 'undefined') {
+ const params = new URLSearchParams(window.location.search);
+ this.debugMode = params.get('debug') === 'true';
+ }
+ }
+
+ /**
+ * Get the current variant for the user
+ * Priority: URL hash > localStorage > new assignment
+ */
+ getVariant(): string {
+ if (typeof window === 'undefined') {
+ return 'control';
+ }
+
+ // Check for forced variant (testing)
+ const forced = this.getForcedVariant();
+ if (forced !== null) {
+ this.log(`Forced variant: ${forced}`);
+ return forced;
+ }
+
+ // Check existing hash
+ const hash = window.location.hash.slice(1);
+ if (hash && this.isValidVariant(hash)) {
+ this.log(`Using hash variant: ${hash}`);
+ this.storeVariant(hash);
+ return hash;
+ }
+
+ // Check localStorage backup
+ const stored = this.getStoredVariant();
+ if (stored && this.isValidVariant(stored)) {
+ this.log(`Using stored variant: ${stored}`);
+ this.setHash(stored);
+ return stored;
+ }
+
+ // Assign new variant
+ const newVariant = this.assignRandomVariant();
+ this.log(`Assigned new variant: ${newVariant}`);
+ this.setHash(newVariant);
+ this.storeVariant(newVariant);
+ return newVariant;
+ }
+
+ /**
+ * Check if a variant is valid
+ */
+ private isValidVariant(variant: string): boolean {
+ return variant === 'control' || this.validVariants.includes(variant);
+ }
+
+ /**
+ * Assign a random variant based on distribution weights
+ */
+ private assignRandomVariant(): string {
+ const random = Math.random() * 100;
+ let cumulative = 0;
+
+ for (const [variant, weight] of Object.entries(this.distribution)) {
+ cumulative += weight;
+ if (random <= cumulative) {
+ return variant;
+ }
+ }
+
+ // Fallback to control
+ return 'control';
+ }
+
+ /**
+ * Set the URL hash
+ */
+ private setHash(variant: string): void {
+ if (typeof window !== 'undefined') {
+ // Don't set hash for control to keep URL clean
+ if (variant === 'control') {
+ // Remove hash if it exists
+ if (window.location.hash) {
+ history.replaceState(null, '', window.location.pathname + window.location.search);
+ }
+ } else {
+ window.location.hash = variant;
+ }
+ }
+ }
+
+ /**
+ * Store variant in localStorage
+ */
+ private storeVariant(variant: string): void {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ try {
+ localStorage.setItem(this.storageKey, variant);
+ // Also store timestamp for analytics
+ localStorage.setItem(`${this.storageKey}_timestamp`, new Date().toISOString());
+ } catch (e) {
+ console.warn('Could not store variant in localStorage:', e);
+ }
+ }
+ }
+
+ /**
+ * Get stored variant from localStorage
+ */
+ private getStoredVariant(): string | null {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ try {
+ return localStorage.getItem(this.storageKey);
+ } catch (e) {
+ console.warn('Could not read variant from localStorage:', e);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get forced variant from URL params (for testing)
+ */
+ private getForcedVariant(): string | null {
+ if (typeof window !== 'undefined') {
+ const params = new URLSearchParams(window.location.search);
+ const forced = params.get('force') || params.get('variant');
+
+ if (forced && this.isValidVariant(forced)) {
+ return forced;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Reset variant assignment (for testing)
+ */
+ reset(): void {
+ if (typeof window !== 'undefined') {
+ // Clear hash
+ if (window.location.hash) {
+ history.replaceState(null, '', window.location.pathname + window.location.search);
+ }
+
+ // Clear storage
+ if (window.localStorage) {
+ localStorage.removeItem(this.storageKey);
+ localStorage.removeItem(`${this.storageKey}_timestamp`);
+ }
+
+ this.log('Variant assignment reset');
+ }
+ }
+
+ /**
+ * Get all active variants (for debugging)
+ */
+ getActiveVariants(): string[] {
+ return ['control', ...Object.keys(this.distribution).filter((v) => v !== 'control')];
+ }
+
+ /**
+ * Get current distribution (for debugging)
+ */
+ getDistribution(): Record {
+ return { ...this.distribution };
+ }
+
+ /**
+ * Log debug messages
+ */
+ private log(message: string): void {
+ if (this.debugMode) {
+ console.log(`[A/B Testing] ${message}`);
+ }
+ }
+
+ /**
+ * Check if we should show debug info
+ */
+ isDebugMode(): boolean {
+ return this.debugMode;
+ }
+}
+
+// Export singleton instance
+export const hashManager = new HashManager();
diff --git a/apps/uload/apps/web/src/lib/actions/clickOutside.ts b/apps/uload/apps/web/src/lib/actions/clickOutside.ts
new file mode 100644
index 000000000..8335869ca
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/actions/clickOutside.ts
@@ -0,0 +1,16 @@
+// Click outside action for Svelte components
+export function clickOutside(node: HTMLElement, callback: () => void) {
+ const handleClick = (event: MouseEvent) => {
+ if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
+ callback();
+ }
+ };
+
+ document.addEventListener('click', handleClick, true);
+
+ return {
+ destroy() {
+ document.removeEventListener('click', handleClick, true);
+ },
+ };
+}
diff --git a/apps/uload/apps/web/src/lib/actions/touch.test.ts b/apps/uload/apps/web/src/lib/actions/touch.test.ts
new file mode 100644
index 000000000..28f0caf72
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/actions/touch.test.ts
@@ -0,0 +1,202 @@
+import { describe, test, expect, beforeEach, vi } from 'vitest';
+import { isTouchDevice, isOptimalTouchTarget } from './touch';
+
+// Mock DOM APIs für Tests
+const mockEventListener = vi.fn();
+const mockRemoveEventListener = vi.fn();
+
+const createMockElement = (width = 44, height = 44) => ({
+ addEventListener: mockEventListener,
+ removeEventListener: mockRemoveEventListener,
+ getBoundingClientRect: () => ({ width, height, top: 0, left: 0, right: width, bottom: height }),
+ style: {},
+ appendChild: vi.fn(),
+ remove: vi.fn(),
+});
+
+// Mock global objects
+Object.defineProperty(window, 'navigator', {
+ value: {
+ maxTouchPoints: 0,
+ userAgent: 'Mozilla/5.0 (Test Browser)',
+ },
+ writable: true,
+});
+
+describe('Touch Utilities', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset touch support
+ delete (window as any).ontouchstart;
+ (window.navigator as any).maxTouchPoints = 0;
+ });
+
+ describe('isTouchDevice', () => {
+ test('should detect touch support via ontouchstart', () => {
+ (window as any).ontouchstart = true;
+ expect(isTouchDevice()).toBe(true);
+ });
+
+ test('should detect touch support via maxTouchPoints', () => {
+ (window.navigator as any).maxTouchPoints = 1;
+ expect(isTouchDevice()).toBe(true);
+ });
+
+ test('should return false for non-touch devices', () => {
+ expect(isTouchDevice()).toBe(false);
+ });
+ });
+
+ describe('isOptimalTouchTarget', () => {
+ test('should return true for 44x44 elements', () => {
+ const element = createMockElement(44, 44);
+ expect(isOptimalTouchTarget(element as any)).toBe(true);
+ });
+
+ test('should return true for larger elements', () => {
+ const element = createMockElement(50, 60);
+ expect(isOptimalTouchTarget(element as any)).toBe(true);
+ });
+
+ test('should return false for small width', () => {
+ const element = createMockElement(30, 44);
+ expect(isOptimalTouchTarget(element as any)).toBe(false);
+ });
+
+ test('should return false for small height', () => {
+ const element = createMockElement(44, 30);
+ expect(isOptimalTouchTarget(element as any)).toBe(false);
+ });
+
+ test('should return false for small elements', () => {
+ const element = createMockElement(20, 20);
+ expect(isOptimalTouchTarget(element as any)).toBe(false);
+ });
+ });
+});
+
+describe('Touch Actions (Integration)', () => {
+ let mockElement: any;
+
+ beforeEach(() => {
+ mockElement = createMockElement();
+ vi.clearAllMocks();
+ });
+
+ describe('Event Registration', () => {
+ test('should register touch and pointer events', () => {
+ // Diese Tests würden die tatsächlichen Touch-Actions testen
+ // Für jetzt testen wir nur die Utility-Funktionen
+ expect(mockEventListener).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Gesture Recognition', () => {
+ test('should calculate touch distances correctly', () => {
+ const touch1 = { clientX: 0, clientY: 0 };
+ const touch2 = { clientX: 100, clientY: 100 };
+
+ // Math.sqrt(100^2 + 100^2) = Math.sqrt(20000) ≈ 141.42
+ const expectedDistance = Math.sqrt(20000);
+ const actualDistance = Math.sqrt(
+ Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2)
+ );
+
+ expect(actualDistance).toBeCloseTo(expectedDistance, 2);
+ });
+
+ test('should detect horizontal swipes', () => {
+ const startTouch = { clientX: 0, clientY: 100 };
+ const endTouch = { clientX: 100, clientY: 100 };
+
+ const deltaX = endTouch.clientX - startTouch.clientX;
+ const deltaY = endTouch.clientY - startTouch.clientY;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+
+ // Horizontal swipe: |deltaX| > |deltaY|
+ expect(absDeltaX).toBeGreaterThan(absDeltaY);
+ expect(deltaX).toBeGreaterThan(0); // Right swipe
+ });
+
+ test('should detect vertical swipes', () => {
+ const startTouch = { clientX: 100, clientY: 0 };
+ const endTouch = { clientX: 100, clientY: 100 };
+
+ const deltaX = endTouch.clientX - startTouch.clientX;
+ const deltaY = endTouch.clientY - startTouch.clientY;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+
+ // Vertical swipe: |deltaY| > |deltaX|
+ expect(absDeltaY).toBeGreaterThan(absDeltaX);
+ expect(deltaY).toBeGreaterThan(0); // Down swipe
+ });
+ });
+
+ describe('Touch Target Validation', () => {
+ test('should validate minimum touch target sizes', () => {
+ const sizes = [
+ { width: 44, height: 44, expected: true },
+ { width: 48, height: 48, expected: true },
+ { width: 40, height: 40, expected: false },
+ { width: 44, height: 40, expected: false },
+ { width: 40, height: 44, expected: false },
+ ];
+
+ sizes.forEach(({ width, height, expected }) => {
+ const element = createMockElement(width, height);
+ expect(isOptimalTouchTarget(element as any)).toBe(expected);
+ });
+ });
+ });
+
+ describe('Performance Considerations', () => {
+ test('should handle rapid touch events', () => {
+ // Simuliere viele schnelle Touch-Events
+ const events = Array.from({ length: 100 }, (_, i) => ({
+ clientX: i,
+ clientY: i,
+ timestamp: Date.now() + i,
+ }));
+
+ // In einer echten Implementation würden wir Throttling/Debouncing testen
+ expect(events).toHaveLength(100);
+
+ // Teste dass Events innerhalb vernünftiger Zeit verarbeitet werden können
+ const startTime = Date.now();
+ events.forEach((event) => {
+ // Simuliere Event-Verarbeitung
+ const deltaX = event.clientX;
+ const deltaY = event.clientY;
+ Math.sqrt(deltaX * deltaX + deltaY * deltaY);
+ });
+ const endTime = Date.now();
+
+ expect(endTime - startTime).toBeLessThan(100); // Sollte sehr schnell sein
+ });
+ });
+
+ describe('Accessibility Considerations', () => {
+ test('should maintain focus accessibility', () => {
+ // Touch-Actions sollten Keyboard-Navigation nicht beeinträchtigen
+ const element = createMockElement();
+
+ // Simuliere dass Element fokussierbar bleibt
+ element.tabIndex = 0;
+ element.setAttribute = vi.fn();
+
+ expect(element.tabIndex).toBe(0);
+ });
+
+ test('should work with screen readers', () => {
+ // Touch-Targets sollten Screen-Reader-kompatibel bleiben
+ const element = createMockElement();
+ element.getAttribute = vi.fn().mockReturnValue('button');
+ element.textContent = 'Touch Button';
+
+ expect(element.getAttribute('role')).toBe('button');
+ expect(element.textContent).toBe('Touch Button');
+ });
+ });
+});
diff --git a/apps/uload/apps/web/src/lib/actions/touch.ts b/apps/uload/apps/web/src/lib/actions/touch.ts
new file mode 100644
index 000000000..d4edcf17f
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/actions/touch.ts
@@ -0,0 +1,343 @@
+// Touch-optimierte Aktionen für mobile Geräte
+import type { Action } from 'svelte/action';
+
+// Touch-optimierte Ripple-Effekte
+export const ripple: Action = (
+ node,
+ options = {}
+) => {
+ const { color = 'rgba(255, 255, 255, 0.3)', duration = 600 } = options;
+
+ let rippleElement: HTMLDivElement | null = null;
+
+ function createRipple(event: PointerEvent | TouchEvent) {
+ // Entferne vorherigen Ripple
+ if (rippleElement) {
+ rippleElement.remove();
+ }
+
+ // Erstelle neuen Ripple
+ rippleElement = document.createElement('div');
+ const rect = node.getBoundingClientRect();
+
+ // Berechne Position des Touches/Clicks
+ let clientX: number, clientY: number;
+ if (event instanceof TouchEvent && event.touches.length > 0) {
+ clientX = event.touches[0].clientX;
+ clientY = event.touches[0].clientY;
+ } else if (event instanceof PointerEvent) {
+ clientX = event.clientX;
+ clientY = event.clientY;
+ } else {
+ // Fallback zur Mitte des Elements
+ clientX = rect.left + rect.width / 2;
+ clientY = rect.top + rect.height / 2;
+ }
+
+ const x = clientX - rect.left;
+ const y = clientY - rect.top;
+ const size = Math.max(rect.width, rect.height) * 2;
+
+ // Style des Ripple-Elements
+ Object.assign(rippleElement.style, {
+ position: 'absolute',
+ top: `${y - size / 2}px`,
+ left: `${x - size / 2}px`,
+ width: `${size}px`,
+ height: `${size}px`,
+ backgroundColor: color,
+ borderRadius: '50%',
+ pointerEvents: 'none',
+ transform: 'scale(0)',
+ transition: `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`,
+ zIndex: '1000',
+ });
+
+ // Stelle sicher, dass das Parent-Element relative Position hat
+ const computedStyle = getComputedStyle(node);
+ if (computedStyle.position === 'static') {
+ node.style.position = 'relative';
+ }
+
+ // Stelle sicher, dass overflow hidden ist für Ripple-Effekt
+ const originalOverflow = node.style.overflow;
+ node.style.overflow = 'hidden';
+
+ node.appendChild(rippleElement);
+
+ // Starte Animation
+ requestAnimationFrame(() => {
+ if (rippleElement) {
+ rippleElement.style.transform = 'scale(1)';
+ rippleElement.style.opacity = '0';
+ }
+ });
+
+ // Entferne Element nach Animation
+ setTimeout(() => {
+ if (rippleElement && rippleElement.parentNode) {
+ rippleElement.remove();
+ rippleElement = null;
+ // Stelle ursprünglichen overflow wieder her
+ node.style.overflow = originalOverflow;
+ }
+ }, duration);
+ }
+
+ // Event Listeners für verschiedene Eingabemethoden
+ node.addEventListener('pointerdown', createRipple);
+ node.addEventListener('touchstart', createRipple, { passive: true });
+
+ return {
+ destroy() {
+ node.removeEventListener('pointerdown', createRipple);
+ node.removeEventListener('touchstart', createRipple);
+ if (rippleElement) {
+ rippleElement.remove();
+ }
+ },
+ };
+};
+
+// Swipe-Gesten erkennen
+interface SwipeOptions {
+ threshold?: number;
+ timeout?: number;
+ onSwipeLeft?: () => void;
+ onSwipeRight?: () => void;
+ onSwipeUp?: () => void;
+ onSwipeDown?: () => void;
+}
+
+export const swipe: Action = (node, options = {}) => {
+ const {
+ threshold = 50,
+ timeout = 300,
+ onSwipeLeft,
+ onSwipeRight,
+ onSwipeUp,
+ onSwipeDown,
+ } = options;
+
+ let startX: number;
+ let startY: number;
+ let startTime: number;
+
+ function handleTouchStart(event: TouchEvent) {
+ if (event.touches.length !== 1) return;
+
+ const touch = event.touches[0];
+ startX = touch.clientX;
+ startY = touch.clientY;
+ startTime = Date.now();
+ }
+
+ function handleTouchEnd(event: TouchEvent) {
+ if (event.changedTouches.length !== 1) return;
+
+ const touch = event.changedTouches[0];
+ const endX = touch.clientX;
+ const endY = touch.clientY;
+ const endTime = Date.now();
+
+ // Prüfe Timeout
+ if (endTime - startTime > timeout) return;
+
+ const deltaX = endX - startX;
+ const deltaY = endY - startY;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+
+ // Prüfe ob Schwellenwert erreicht wurde
+ if (Math.max(absDeltaX, absDeltaY) < threshold) return;
+
+ // Bestimme Swipe-Richtung
+ if (absDeltaX > absDeltaY) {
+ // Horizontaler Swipe
+ if (deltaX > 0) {
+ onSwipeRight?.();
+ } else {
+ onSwipeLeft?.();
+ }
+ } else {
+ // Vertikaler Swipe
+ if (deltaY > 0) {
+ onSwipeDown?.();
+ } else {
+ onSwipeUp?.();
+ }
+ }
+ }
+
+ node.addEventListener('touchstart', handleTouchStart, { passive: true });
+ node.addEventListener('touchend', handleTouchEnd, { passive: true });
+
+ return {
+ destroy() {
+ node.removeEventListener('touchstart', handleTouchStart);
+ node.removeEventListener('touchend', handleTouchEnd);
+ },
+ };
+};
+
+// Long Press für mobile Geräte
+interface LongPressOptions {
+ duration?: number;
+ onLongPress?: (event: PointerEvent | TouchEvent) => void;
+}
+
+export const longPress: Action = (node, options = {}) => {
+ const { duration = 500, onLongPress } = options;
+
+ let timer: ReturnType;
+ let startEvent: PointerEvent | TouchEvent;
+
+ function startLongPress(event: PointerEvent | TouchEvent) {
+ startEvent = event;
+ timer = setTimeout(() => {
+ onLongPress?.(startEvent);
+ }, duration);
+ }
+
+ function cancelLongPress() {
+ clearTimeout(timer);
+ }
+
+ // Touch Events
+ node.addEventListener('touchstart', startLongPress, { passive: true });
+ node.addEventListener('touchend', cancelLongPress, { passive: true });
+ node.addEventListener('touchcancel', cancelLongPress, { passive: true });
+ node.addEventListener('touchmove', cancelLongPress, { passive: true });
+
+ // Pointer Events (für bessere Unterstützung)
+ node.addEventListener('pointerdown', startLongPress);
+ node.addEventListener('pointerup', cancelLongPress);
+ node.addEventListener('pointercancel', cancelLongPress);
+ node.addEventListener('pointermove', cancelLongPress);
+
+ return {
+ destroy() {
+ clearTimeout(timer);
+ node.removeEventListener('touchstart', startLongPress);
+ node.removeEventListener('touchend', cancelLongPress);
+ node.removeEventListener('touchcancel', cancelLongPress);
+ node.removeEventListener('touchmove', cancelLongPress);
+ node.removeEventListener('pointerdown', startLongPress);
+ node.removeEventListener('pointerup', cancelLongPress);
+ node.removeEventListener('pointercancel', cancelLongPress);
+ node.removeEventListener('pointermove', cancelLongPress);
+ },
+ };
+};
+
+// Touch-freundliche Drag & Drop
+interface TouchDragOptions {
+ onDragStart?: (event: PointerEvent | TouchEvent) => void;
+ onDragMove?: (event: PointerEvent | TouchEvent, deltaX: number, deltaY: number) => void;
+ onDragEnd?: (event: PointerEvent | TouchEvent) => void;
+ threshold?: number;
+}
+
+export const touchDrag: Action = (node, options = {}) => {
+ const { onDragStart, onDragMove, onDragEnd, threshold = 5 } = options;
+
+ let isDragging = false;
+ let startX: number;
+ let startY: number;
+ let lastX: number;
+ let lastY: number;
+
+ function handleStart(event: PointerEvent | TouchEvent) {
+ let clientX: number, clientY: number;
+
+ if (event instanceof TouchEvent && event.touches.length > 0) {
+ clientX = event.touches[0].clientX;
+ clientY = event.touches[0].clientY;
+ } else if (event instanceof PointerEvent) {
+ clientX = event.clientX;
+ clientY = event.clientY;
+ } else {
+ return;
+ }
+
+ startX = lastX = clientX;
+ startY = lastY = clientY;
+ isDragging = false;
+ }
+
+ function handleMove(event: PointerEvent | TouchEvent) {
+ let clientX: number, clientY: number;
+
+ if (event instanceof TouchEvent && event.touches.length > 0) {
+ clientX = event.touches[0].clientX;
+ clientY = event.touches[0].clientY;
+ } else if (event instanceof PointerEvent) {
+ clientX = event.clientX;
+ clientY = event.clientY;
+ } else {
+ return;
+ }
+
+ const deltaX = clientX - lastX;
+ const deltaY = clientY - lastY;
+ const totalDeltaX = clientX - startX;
+ const totalDeltaY = clientY - startY;
+
+ // Prüfe ob Drag-Threshold erreicht wurde
+ if (!isDragging && (Math.abs(totalDeltaX) > threshold || Math.abs(totalDeltaY) > threshold)) {
+ isDragging = true;
+ onDragStart?.(event);
+ }
+
+ if (isDragging) {
+ onDragMove?.(event, deltaX, deltaY);
+ }
+
+ lastX = clientX;
+ lastY = clientY;
+ }
+
+ function handleEnd(event: PointerEvent | TouchEvent) {
+ if (isDragging) {
+ onDragEnd?.(event);
+ }
+ isDragging = false;
+ }
+
+ // Touch Events
+ node.addEventListener('touchstart', handleStart, { passive: true });
+ node.addEventListener('touchmove', handleMove, { passive: false });
+ node.addEventListener('touchend', handleEnd, { passive: true });
+ node.addEventListener('touchcancel', handleEnd, { passive: true });
+
+ // Pointer Events
+ node.addEventListener('pointerdown', handleStart);
+ node.addEventListener('pointermove', handleMove);
+ node.addEventListener('pointerup', handleEnd);
+ node.addEventListener('pointercancel', handleEnd);
+
+ return {
+ destroy() {
+ node.removeEventListener('touchstart', handleStart);
+ node.removeEventListener('touchmove', handleMove);
+ node.removeEventListener('touchend', handleEnd);
+ node.removeEventListener('touchcancel', handleEnd);
+ node.removeEventListener('pointerdown', handleStart);
+ node.removeEventListener('pointermove', handleMove);
+ node.removeEventListener('pointerup', handleEnd);
+ node.removeEventListener('pointercancel', handleEnd);
+ },
+ };
+};
+
+// Utility: Touch-Gerät erkennen
+export function isTouchDevice(): boolean {
+ return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
+}
+
+// Utility: Optimale Touch-Target-Größe prüfen
+export function isOptimalTouchTarget(element: HTMLElement): boolean {
+ const rect = element.getBoundingClientRect();
+ const minSize = 44; // 44px ist die empfohlene Mindestgröße für Touch-Targets
+ return rect.width >= minSize && rect.height >= minSize;
+}
diff --git a/apps/uload/apps/web/src/lib/analytics.ts b/apps/uload/apps/web/src/lib/analytics.ts
new file mode 100644
index 000000000..59c59f5da
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/analytics.ts
@@ -0,0 +1,145 @@
+/**
+ * Umami Analytics Event Tracking
+ * Provides type-safe event tracking with Umami Analytics
+ */
+
+declare global {
+ interface Window {
+ umami?: {
+ track: (eventName: string, data?: Record) => void;
+ };
+ }
+}
+
+/**
+ * Event names for consistent tracking across the application
+ */
+export const EVENTS = {
+ // Link events
+ LINK_CREATED: 'link-created',
+ LINK_EDITED: 'link-edited',
+ LINK_DELETED: 'link-deleted',
+ LINK_CLICKED: 'link-clicked',
+ LINK_COPIED: 'link-copied',
+ LINK_SHARED: 'link-shared',
+ LINK_QR_GENERATED: 'link-qr-generated',
+ LINK_QR_DOWNLOADED: 'link-qr-downloaded',
+ LINK_EXPIRED: 'link-expired',
+ LINK_PASSWORD_SET: 'link-password-set',
+ LINK_PASSWORD_UNLOCKED: 'link-password-unlocked',
+
+ // User events
+ USER_SIGNUP: 'user-signup',
+ USER_LOGIN: 'user-login',
+ USER_LOGOUT: 'user-logout',
+ USER_PROFILE_UPDATED: 'user-profile-updated',
+ USER_PASSWORD_RESET: 'user-password-reset',
+
+ // Dashboard events
+ DASHBOARD_VIEWED: 'dashboard-viewed',
+ ANALYTICS_VIEWED: 'analytics-viewed',
+ PROFILE_VIEWED: 'profile-viewed',
+
+ // Search and filter
+ SEARCH_PERFORMED: 'search-performed',
+ FILTER_APPLIED: 'filter-applied',
+ SORT_CHANGED: 'sort-changed',
+
+ // Error events
+ ERROR_OCCURRED: 'error-occurred',
+ RATE_LIMITED: 'rate-limited',
+} as const;
+
+export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
+
+/**
+ * Track an event with Umami Analytics
+ * @param eventName - The name of the event to track
+ * @param data - Optional data to send with the event (will be converted to strings)
+ */
+export function trackEvent(eventName: EventName | string, data?: Record): void {
+ if (typeof window === 'undefined' || !window.umami) {
+ console.debug('Umami not available, skipping event:', eventName, data);
+ return;
+ }
+
+ try {
+ // Convert all data values to strings (Umami requirement)
+ const stringData = data
+ ? Object.entries(data).reduce(
+ (acc, [key, value]) => {
+ acc[key] = String(value);
+ return acc;
+ },
+ {} as Record
+ )
+ : undefined;
+
+ window.umami.track(eventName, stringData);
+ console.debug('Event tracked:', eventName, stringData);
+ } catch (error) {
+ console.error('Failed to track event:', error);
+ }
+}
+
+/**
+ * Track a link click event
+ */
+export function trackLinkClick(linkData: {
+ shortCode: string;
+ username: string;
+ hasPassword?: boolean;
+ isExpiring?: boolean;
+}): void {
+ trackEvent(EVENTS.LINK_CLICKED, {
+ short_code: linkData.shortCode,
+ username: linkData.username,
+ has_password: linkData.hasPassword || false,
+ is_expiring: linkData.isExpiring || false,
+ });
+}
+
+/**
+ * Track a link creation event
+ */
+export function trackLinkCreated(linkData: {
+ shortCode: string;
+ hasPassword?: boolean;
+ hasExpiry?: boolean;
+ hasClickLimit?: boolean;
+}): void {
+ trackEvent(EVENTS.LINK_CREATED, {
+ short_code: linkData.shortCode,
+ has_password: linkData.hasPassword || false,
+ has_expiry: linkData.hasExpiry || false,
+ has_click_limit: linkData.hasClickLimit || false,
+ });
+}
+
+/**
+ * Track user authentication events
+ */
+export function trackAuth(type: 'signup' | 'login' | 'logout', method?: string): void {
+ const eventMap = {
+ signup: EVENTS.USER_SIGNUP,
+ login: EVENTS.USER_LOGIN,
+ logout: EVENTS.USER_LOGOUT,
+ };
+
+ trackEvent(eventMap[type], method ? { method } : undefined);
+}
+
+/**
+ * Track error events
+ */
+export function trackError(error: {
+ type: string;
+ message?: string;
+ code?: string | number;
+}): void {
+ trackEvent(EVENTS.ERROR_OCCURRED, {
+ error_type: error.type,
+ error_message: error.message || 'Unknown error',
+ error_code: error.code || 'unknown',
+ });
+}
diff --git a/apps/uload/apps/web/src/lib/api/feedback.ts b/apps/uload/apps/web/src/lib/api/feedback.ts
new file mode 100644
index 000000000..eff57a51d
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/api/feedback.ts
@@ -0,0 +1,15 @@
+/**
+ * Feedback Service Instance for uLoad Web App
+ */
+
+import { createFeedbackService } from '@manacore/shared-feedback-service';
+import { pb } from '$lib/pocketbase';
+import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
+
+const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
+
+export const feedbackService = createFeedbackService({
+ apiUrl: MANA_AUTH_URL,
+ appId: 'uload',
+ getAuthToken: async () => pb.authStore.token || '',
+});
diff --git a/apps/uload/apps/web/src/lib/assets/favicon.svg b/apps/uload/apps/web/src/lib/assets/favicon.svg
new file mode 100644
index 000000000..cc5dc66a3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+svelte-logo
\ No newline at end of file
diff --git a/apps/uload/apps/web/src/lib/auth-helper.ts b/apps/uload/apps/web/src/lib/auth-helper.ts
new file mode 100644
index 000000000..03753c0ae
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/auth-helper.ts
@@ -0,0 +1,146 @@
+import { pb } from './pocketbase';
+import { generateUsernameFromEmail } from './username';
+
+export interface RegisterData {
+ email: string;
+ password: string;
+ passwordConfirm: string;
+}
+
+export interface RegisterResult {
+ success: boolean;
+ user?: any;
+ error?: string;
+}
+
+export async function registerUser(data: RegisterData): Promise {
+ try {
+ const email = data.email.toLowerCase().trim();
+
+ // Basic validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return { success: false, error: 'Please enter a valid email address' };
+ }
+
+ if (data.password !== data.passwordConfirm) {
+ return { success: false, error: 'Passwords do not match' };
+ }
+
+ if (data.password.length < 8) {
+ return { success: false, error: 'Password must be at least 8 characters' };
+ }
+
+ // Generate unique username
+ let username = generateUsernameFromEmail(email);
+ let attempts = 0;
+
+ // Try to find unique username
+ while (attempts < 10) {
+ try {
+ await pb.collection('users').getFirstListItem(`username="${username}"`);
+ // Username exists, add random suffix
+ username = `${generateUsernameFromEmail(email)}${Math.floor(Math.random() * 9999)}`;
+ attempts++;
+ } catch {
+ // Username is available
+ break;
+ }
+ }
+
+ // Create user with minimal data - DO NOT provide ID
+ const userData = {
+ email,
+ password: data.password,
+ passwordConfirm: data.passwordConfirm,
+ username,
+ emailVisibility: true,
+ };
+
+ console.log('Creating user with minimal data:', { email, username });
+ console.log('PocketBase URL:', pb.baseUrl);
+
+ const newUser = await pb.collection('users').create(userData);
+
+ // Auto-login after registration
+ try {
+ await pb.collection('users').authWithPassword(email, data.password);
+ } catch (loginErr) {
+ console.error('Auto-login failed:', loginErr);
+ // User created but login failed - still success
+ }
+
+ return {
+ success: true,
+ user: newUser,
+ };
+ } catch (error: any) {
+ console.error('Registration error:', error);
+
+ // Parse error details
+ const errorData = error?.response?.data || error?.data?.data || error?.data || {};
+
+ // Log full error for debugging
+ console.error('Full registration error:', JSON.stringify(errorData, null, 2));
+
+ // Handle specific errors
+ if (errorData.email?.message) {
+ if (errorData.email.message.includes('already exists')) {
+ return { success: false, error: 'This email is already registered. Please login instead.' };
+ }
+ return { success: false, error: errorData.email.message };
+ }
+
+ if (errorData.username?.message) {
+ // Try again with different username
+ console.log('Username conflict, this should not happen');
+ return { success: false, error: 'Username generation failed. Please try again.' };
+ }
+
+ if (errorData.password?.message) {
+ return { success: false, error: errorData.password.message };
+ }
+
+ if (errorData.id?.message) {
+ // ID error - this is the main issue we're trying to fix
+ console.error('Critical: ID field error detected');
+ console.error('ID error details:', errorData.id);
+ // Try to understand the error
+ if (errorData.id.message.includes('blank') || errorData.id.message.includes('required')) {
+ console.error('PocketBase is not auto-generating IDs!');
+ }
+ return {
+ success: false,
+ error: 'Registration system error. Please try again later or contact support.',
+ };
+ }
+
+ // Check for any field-level errors
+ for (const field in errorData) {
+ if (typeof errorData[field] === 'object' && errorData[field]?.message) {
+ return { success: false, error: `${field}: ${errorData[field].message}` };
+ }
+ }
+
+ // Generic error
+ return {
+ success: false,
+ error: error?.message || 'Registration failed. Please try again.',
+ };
+ }
+}
+
+export async function loginUser(email: string, password: string) {
+ try {
+ const authData = await pb
+ .collection('users')
+ .authWithPassword(email.toLowerCase().trim(), password);
+ return { success: true, user: authData.record };
+ } catch (error: any) {
+ console.error('Login error:', error);
+ return {
+ success: false,
+ error: 'Invalid email or password',
+ };
+ }
+}
diff --git a/apps/uload/apps/web/src/lib/cache.test.ts b/apps/uload/apps/web/src/lib/cache.test.ts
new file mode 100644
index 000000000..eee6c5fb0
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/cache.test.ts
@@ -0,0 +1,219 @@
+import { describe, test, expect, beforeEach, vi } from 'vitest';
+import { cache, cacheKey, CacheKeys } from './cache';
+
+describe('Cache System', () => {
+ beforeEach(() => {
+ cache.clear();
+ });
+
+ describe('Basic Cache Operations', () => {
+ test('should set and get values', () => {
+ const key = 'test-key';
+ const value = { data: 'test' };
+
+ cache.set(key, value);
+ const result = cache.get(key);
+
+ expect(result).toEqual(value);
+ });
+
+ test('should return null for non-existent keys', () => {
+ const result = cache.get('non-existent');
+ expect(result).toBeNull();
+ });
+
+ test('should handle TTL expiration', async () => {
+ const key = 'ttl-test';
+ const value = 'test-value';
+ const shortTTL = 10; // 10ms
+
+ cache.set(key, value, shortTTL);
+
+ // Should be available immediately
+ expect(cache.get(key)).toBe(value);
+
+ // Wait for TTL to expire
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ // Should be null after expiration
+ expect(cache.get(key)).toBeNull();
+ });
+
+ test('should delete specific keys', () => {
+ cache.set('key1', 'value1');
+ cache.set('key2', 'value2');
+
+ cache.delete('key1');
+
+ expect(cache.get('key1')).toBeNull();
+ expect(cache.get('key2')).toBe('value2');
+ });
+
+ test('should clear all keys', () => {
+ cache.set('key1', 'value1');
+ cache.set('key2', 'value2');
+
+ cache.clear();
+
+ expect(cache.get('key1')).toBeNull();
+ expect(cache.get('key2')).toBeNull();
+ });
+ });
+
+ describe('Cache Key Generation', () => {
+ test('should generate cache keys correctly', () => {
+ const key = cacheKey('user', 123, 'profile');
+ expect(key).toBe('user:123:profile');
+ });
+
+ test('should handle different data types in keys', () => {
+ const key = cacheKey('prefix', 42, 'suffix', true);
+ expect(key).toBe('prefix:42:suffix:true');
+ });
+
+ test('should generate predefined cache keys', () => {
+ expect(CacheKeys.userLinks('user123')).toBe('user:user123:links');
+ expect(CacheKeys.linkStats('link456')).toBe('link:link456:stats');
+ expect(CacheKeys.userProfile('john')).toBe('profile:john');
+ expect(CacheKeys.linkRedirect('abc123')).toBe('redirect:abc123');
+ });
+ });
+
+ describe('Cache Cleanup', () => {
+ test('should cleanup expired entries', async () => {
+ const shortTTL = 10; // 10ms
+
+ cache.set('key1', 'value1', shortTTL);
+ cache.set('key2', 'value2', 60000); // 1 minute
+
+ // Wait for first key to expire
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ cache.cleanup();
+
+ expect(cache.get('key1')).toBeNull();
+ expect(cache.get('key2')).toBe('value2');
+ });
+ });
+
+ describe('Type Safety', () => {
+ test('should handle typed values correctly', () => {
+ interface TestData {
+ id: string;
+ name: string;
+ count: number;
+ }
+
+ const key = 'typed-test';
+ const value: TestData = { id: '123', name: 'test', count: 42 };
+
+ cache.set(key, value);
+ const result = cache.get(key);
+
+ expect(result).toEqual(value);
+ expect(result?.id).toBe('123');
+ expect(result?.count).toBe(42);
+ });
+
+ test('should handle arrays and objects', () => {
+ const arrayKey = 'array-test';
+ const arrayValue = [1, 2, 3, 'test'];
+
+ const objectKey = 'object-test';
+ const objectValue = {
+ nested: { deep: true },
+ array: [1, 2, 3],
+ date: new Date().toISOString(),
+ };
+
+ cache.set(arrayKey, arrayValue);
+ cache.set(objectKey, objectValue);
+
+ expect(cache.get(arrayKey)).toEqual(arrayValue);
+ expect(cache.get(objectKey)).toEqual(objectValue);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ test('should handle undefined and null values', () => {
+ cache.set('null-test', null);
+ cache.set('undefined-test', undefined);
+
+ expect(cache.get('null-test')).toBeNull();
+ expect(cache.get('undefined-test')).toBeUndefined();
+ });
+
+ test('should handle empty strings and zero values', () => {
+ cache.set('empty-string', '');
+ cache.set('zero', 0);
+ cache.set('false', false);
+
+ expect(cache.get('empty-string')).toBe('');
+ expect(cache.get('zero')).toBe(0);
+ expect(cache.get('false')).toBe(false);
+ });
+
+ test('should handle concurrent access', () => {
+ const key = 'concurrent-test';
+
+ // Simulate concurrent writes
+ cache.set(key, 'value1');
+ cache.set(key, 'value2');
+ cache.set(key, 'value3');
+
+ // Last write should win
+ expect(cache.get(key)).toBe('value3');
+ });
+
+ test('should handle very long keys', () => {
+ const longKey = 'a'.repeat(1000);
+ const value = 'test-value';
+
+ cache.set(longKey, value);
+ expect(cache.get(longKey)).toBe(value);
+ });
+ });
+
+ describe('Performance', () => {
+ test('should handle large number of entries efficiently', () => {
+ const startTime = Date.now();
+ const entryCount = 1000;
+
+ // Set many entries
+ for (let i = 0; i < entryCount; i++) {
+ cache.set(`key-${i}`, `value-${i}`);
+ }
+
+ // Get many entries
+ for (let i = 0; i < entryCount; i++) {
+ expect(cache.get(`key-${i}`)).toBe(`value-${i}`);
+ }
+
+ const endTime = Date.now();
+ const duration = endTime - startTime;
+
+ // Should complete within reasonable time (1 second for 1000 entries)
+ expect(duration).toBeLessThan(1000);
+ });
+
+ test('should handle large values efficiently', () => {
+ const largeValue = {
+ data: 'x'.repeat(10000),
+ array: Array(1000).fill('test'),
+ nested: {
+ deep: {
+ very: {
+ deep: 'value',
+ },
+ },
+ },
+ };
+
+ const key = 'large-value-test';
+ cache.set(key, largeValue);
+
+ const result = cache.get(key);
+ expect(result).toEqual(largeValue);
+ });
+ });
+});
diff --git a/apps/uload/apps/web/src/lib/cache.ts b/apps/uload/apps/web/src/lib/cache.ts
new file mode 100644
index 000000000..2b6a4f986
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/cache.ts
@@ -0,0 +1,93 @@
+// Simple in-memory cache with TTL for server-side caching
+// In Produktion kann das durch Redis/Valkey ersetzt werden
+
+interface CacheEntry {
+ data: T;
+ expiresAt: number;
+}
+
+class SimpleCache {
+ private cache = new Map>();
+ private readonly defaultTTL = 5 * 60 * 1000; // 5 Minuten default
+
+ set(key: string, data: T, ttlMs: number = this.defaultTTL): void {
+ const expiresAt = Date.now() + ttlMs;
+ this.cache.set(key, { data, expiresAt });
+ }
+
+ get(key: string): T | null {
+ const entry = this.cache.get(key);
+ if (!entry) return null;
+
+ if (Date.now() > entry.expiresAt) {
+ this.cache.delete(key);
+ return null;
+ }
+
+ return entry.data;
+ }
+
+ delete(key: string): void {
+ this.cache.delete(key);
+ }
+
+ clear(): void {
+ this.cache.clear();
+ }
+
+ // Periodisches Cleanup abgelaufener Einträge
+ cleanup(): void {
+ const now = Date.now();
+ for (const [key, entry] of this.cache.entries()) {
+ if (now > entry.expiresAt) {
+ this.cache.delete(key);
+ }
+ }
+ }
+}
+
+// Globale Cache-Instanz
+export const cache = new SimpleCache();
+
+// Cleanup alle 10 Minuten
+if (typeof setInterval !== 'undefined') {
+ setInterval(() => cache.cleanup(), 10 * 60 * 1000);
+}
+
+// Helper Funktionen für häufige Cache-Pattern
+export function cacheKey(...parts: (string | number)[]): string {
+ return parts.join(':');
+}
+
+// Cache-Decorator für async Funktionen
+export function cached(keyGenerator: (...args: any[]) => string, ttlMs: number = 5 * 60 * 1000) {
+ return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
+ const originalMethod = descriptor.value;
+
+ descriptor.value = async function (...args: any[]): Promise {
+ const key = keyGenerator(...args);
+ const cached = cache.get(key);
+
+ if (cached !== null) {
+ return cached;
+ }
+
+ const result = await originalMethod.apply(this, args);
+ cache.set(key, result, ttlMs);
+ return result;
+ };
+
+ return descriptor;
+ };
+}
+
+// Spezielle Cache-Keys für uLoad
+export const CacheKeys = {
+ userLinks: (userId: string) => cacheKey('user', userId, 'links'),
+ linkStats: (linkId: string) => cacheKey('link', linkId, 'stats'),
+ userProfile: (username: string) => cacheKey('profile', username),
+ linkRedirect: (shortCode: string) => cacheKey('redirect', shortCode),
+ analyticsDaily: (linkId: string, date: string) => cacheKey('analytics', linkId, date),
+ userCards: (userId: string) => cacheKey('user', userId, 'cards'),
+ publicCard: (username: string, cardId: string) => cacheKey('public', username, cardId),
+} as const;
diff --git a/apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte b/apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte
new file mode 100644
index 000000000..bda3e7e7c
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/AccountSwitcher.svelte
@@ -0,0 +1,180 @@
+
+
+
+
+ {#if currentAccount}
+ {#if accounts.viewingAs !== accounts.currentUser?.id}
+
+ {:else}
+
+ {/if}
+
+ {getAccountDisplayName(currentAccount)}
+
+
+ {:else if accounts.currentUser}
+
+
+ {getAccountDisplayName(accounts.currentUser)}
+
+
+ {/if}
+
+
+ {#if showDropdown}
+
+
+ {#if accounts.currentUser}
+
+
+ {m.account_my_account()}
+
+
switchToAccount(accounts.currentUser.id)}
+ class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+ {getAccountDisplayName(accounts.currentUser)}
+
+
+ @{accounts.currentUser.username}
+
+
+ {#if accounts.viewingAs === accounts.currentUser.id}
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if accounts.sharedAccounts && accounts.sharedAccounts.length > 0}
+
+
+ {m.account_team_accounts()}
+
+ {#each accounts.sharedAccounts as shared}
+ {#if shared.expand?.owner}
+
switchToAccount(shared.owner)}
+ class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+ {getAccountDisplayName(shared.expand.owner)}
+
+
+
+ @{shared.expand.owner.username}
+
+
+ {m.account_team_member()}
+
+
+
+ {#if accounts.viewingAs === shared.owner}
+
+ {/if}
+
+ {/if}
+ {/each}
+
+ {:else}
+
+
+
+ {m.account_no_team_accounts()}
+
+
+ {m.account_team_invite_info()}
+
+
+ {/if}
+
+
+
+
+
+
+
+ {m.account_add_account()}
+
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/Button.svelte b/apps/uload/apps/web/src/lib/components/Button.svelte
new file mode 100644
index 000000000..a3485a353
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/Button.svelte
@@ -0,0 +1,48 @@
+
+
+
+ {@render children()}
+
diff --git a/apps/uload/apps/web/src/lib/components/DataTable.svelte b/apps/uload/apps/web/src/lib/components/DataTable.svelte
new file mode 100644
index 000000000..8681ef62a
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/DataTable.svelte
@@ -0,0 +1,186 @@
+
+
+{#if items && items.length > 0}
+
+ {#if title}
+
+
+ {title}
+
+
+ {/if}
+
+ {#if isMobile && renderMobileCard}
+
+
+ {#each items as item}
+ {@html renderMobileCard(item)}
+ {/each}
+
+ {:else}
+
+
+
+ {#each visibleColumns as column}
+
+ {column.label}
+
+ {/each}
+
+
+
+
+ {#each items as item}
+
+
+ {#each visibleColumns as column}
+
+ {#if column.render}
+ {@html column.render(item)}
+ {:else if column.key.includes('.')}
+
+ {@const keys = column.key.split('.')}
+ {@const value = keys.reduce((obj, key) => obj?.[key], item)}
+ {value || '-'}
+ {:else}
+ {item[column.key] || '-'}
+ {/if}
+
+ {/each}
+
+
+
+
+ {#if renderMobileCard}
+ {@html renderMobileCard(item)}
+ {:else}
+
+
+ {#each columns.filter((col) => !col.hideOnMobile) as column}
+
+ {column.label}:
+
+ {#if column.render}
+ {@html column.render(item)}
+ {:else if column.key.includes('.')}
+ {@const keys = column.key.split('.')}
+ {@const value = keys.reduce((obj, key) => obj?.[key], item)}
+ {value || '-'}
+ {:else}
+ {item[column.key] || '-'}
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+{:else}
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/Dropdown.svelte b/apps/uload/apps/web/src/lib/components/Dropdown.svelte
new file mode 100644
index 000000000..73c29e2a0
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/Dropdown.svelte
@@ -0,0 +1,197 @@
+
+
+
+
+ {#if buttonIcon}
+ {@html buttonIcon}
+ {/if}
+ {buttonText}
+
+
+
+
+
+ {#if isOpen}
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte b/apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte
new file mode 100644
index 000000000..dd93ca8dd
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/FloatingSidebar.svelte
@@ -0,0 +1,644 @@
+
+
+{#if user && mounted}
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/Footer.svelte b/apps/uload/apps/web/src/lib/components/Footer.svelte
new file mode 100644
index 000000000..357e8f4d0
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/Footer.svelte
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+ Verkürzen Sie Ihre URLs schnell und einfach. Mit erweiterten Funktionen für professionelle
+ Nutzer.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ © {currentYear} uload. Alle Rechte vorbehalten.
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte b/apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte
new file mode 100644
index 000000000..abe6f2d2d
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/LanguageSwitcher.svelte
@@ -0,0 +1,92 @@
+
+
+
+
(showDropdown = !showDropdown)}
+ class="flex items-center gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
+ aria-label="Change language"
+ >
+ {currentLanguage.flag}
+ {currentLanguage.name}
+
+
+
+
+
+ {#if showDropdown}
+
+ {#each languages as lang}
+
changeLanguage(lang.code)}
+ class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 {lang.code ===
+ currentLanguage.code
+ ? 'bg-gray-50 dark:bg-gray-700/50'
+ : ''}"
+ >
+ {lang.flag}
+ {lang.name}
+ {#if lang.code === currentLanguage.code}
+
+
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+ {
+ // Close dropdown when clicking outside
+ if (showDropdown && !(e.target as HTMLElement)?.closest('.relative')) {
+ showDropdown = false;
+ }
+ }}
+/>
diff --git a/apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte b/apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte
new file mode 100644
index 000000000..634f286bd
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/LinkUsageBar.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+ {#if usageInfo.unlimited}
+ Unbegrenzte Links
+ {:else}
+ Link-Nutzung diesen Monat
+ {/if}
+
+
+ {#if !usageInfo.unlimited}
+
+ {usageInfo.current} / {usageInfo.limit}
+
+ {/if}
+
+
+ {#if !usageInfo.unlimited}
+
+
+
+
+
+ {#if usageInfo.status === 'danger'}
+
+ Monatslimit erreicht! Upgrade für mehr Links.
+
+ {:else if usageInfo.status === 'warning'}
+
+ {usageInfo.limit - usageInfo.current} Links verbleibend
+
+ {:else}
+
+ {usageInfo.limit - usageInfo.current} Links verbleibend
+
+ {/if}
+
+ {:else}
+
+ 🎉 Du hast unbegrenzten Zugang zu allen Features!
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/MobileSidebar.svelte b/apps/uload/apps/web/src/lib/components/MobileSidebar.svelte
new file mode 100644
index 000000000..adbcae965
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/MobileSidebar.svelte
@@ -0,0 +1,306 @@
+
+
+{#if user && open}
+
+
+
+
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/Navigation.svelte b/apps/uload/apps/web/src/lib/components/Navigation.svelte
new file mode 100644
index 000000000..c7a41a84a
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/Navigation.svelte
@@ -0,0 +1,840 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if !user}
+
+ {m.nav_login()}
+
+
+ {m.nav_register()}
+
+ {:else}
+
+
+ {/if}
+
+
+
+
+
+
(mobileMenuOpen = !mobileMenuOpen)}
+ class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text xl:hidden"
+ aria-label="Menu"
+ aria-expanded={mobileMenuOpen}
+ >
+
+ {#if mobileMenuOpen}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ uload
+
+
+
+
+
+
+
(mobileMenuOpen = !mobileMenuOpen)}
+ class="hover:bg-theme-surface-hover/50 relative z-10 flex flex-1 items-center justify-center gap-2 px-6 py-4 transition-colors"
+ aria-label="Menu"
+ aria-expanded={mobileMenuOpen}
+ >
+ Menu
+
+ {#if mobileMenuOpen}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+{#if mobileMenuOpen}
+ (mobileMenuOpen = false)}
+ onkeydown={(e) => e.key === 'Escape' && (mobileMenuOpen = false)}
+ aria-label="Close mobile menu"
+ style="top: 0;"
+ >
+{/if}
+
+
+{#if mobileMenuOpen}
+
+
+
+ {#if user}
+
+
+
+
+
+
+
+
+ {:else}
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/NotificationBell.svelte b/apps/uload/apps/web/src/lib/components/NotificationBell.svelte
new file mode 100644
index 000000000..3c4a2ff19
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/NotificationBell.svelte
@@ -0,0 +1,270 @@
+
+
+
+
+
(showDropdown = !showDropdown)}
+ class="relative p-2 text-theme-text-muted transition-colors hover:text-theme-text"
+ aria-label="Benachrichtigungen"
+ aria-expanded={showDropdown}
+ aria-haspopup="true"
+ >
+
+ {#if $unreadCount > 0}
+
+ {$unreadCount > 9 ? '9+' : $unreadCount}
+
+ {/if}
+
+
+
+ {#if showDropdown}
+
+
+
+
+
Benachrichtigungen
+
+ {#if $unreadCount > 0}
+
+ Alle als gelesen markieren
+
+ {/if}
+ (showDropdown = false)}
+ class="rounded-md p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ >
+
+
+
+
+
+
+
+
+ {#if $notifications.loading}
+
+
+
Lade Benachrichtigungen...
+
+ {:else if $notifications.notifications.length === 0}
+
+
+
Keine Benachrichtigungen
+
+ {:else}
+
+ {#each $notifications.notifications as notification, i}
+
+
+
+
+
+ {getNotificationIcon(notification.type)}
+
+
+
+
+
+
+
handleAction(notification)} class="flex-1 text-left">
+
+ {notification.title}
+
+
+ {notification.message}
+
+
+ {formatTime(notification.created)}
+
+
+
+
+
+ {#if !notification.read}
+ {
+ e.stopPropagation();
+ handleMarkAsRead(notification.id);
+ }}
+ class="rounded p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-primary"
+ title="Als gelesen markieren"
+ >
+
+
+ {/if}
+ {
+ e.stopPropagation();
+ handleDelete(notification.id);
+ }}
+ class="rounded p-1 text-theme-text-muted transition-colors hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
+ title="Löschen"
+ >
+
+
+
+
+
+ {#if notification.type === 'team_invite' && notification.action_url}
+
{
+ e.stopPropagation();
+ handleAction(notification);
+ }}
+ class="bg-theme-primary/10 hover:bg-theme-primary/20 mt-2 inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium text-theme-primary transition-colors"
+ >
+ Einladung annehmen
+
+
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte b/apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte
new file mode 100644
index 000000000..6fb058dd3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/SimpleAccountSwitcher.svelte
@@ -0,0 +1,145 @@
+
+
+{#if user && sharedAccounts.length > 0}
+
+
(isOpen = !isOpen)}
+ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
+ >
+
+ {#if isViewingShared}
+
+ {:else}
+
+ {/if}
+ {currentAccountName()}
+
+
+
+
+ {#if isOpen}
+
+
(isOpen = false)} class="fixed inset-0 z-40" aria-label="Close menu"
+ >
+
+
+
+
+
+
switchAccount(user.id)}
+ class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+
My Account
+
{user.email}
+
+
+ {#if currentAccount === user.id}
+
+ {/if}
+
+
+ {#if sharedAccounts.length > 0}
+
+
+
+
+
+ {#each sharedAccounts as shared}
+ {#if shared.invitation_status === 'accepted'}
+
switchAccount(shared.owner)}
+ class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+
+ {shared.expand?.owner?.name ||
+ shared.expand?.owner?.username ||
+ 'Team Account'}
+
+
+ {shared.expand?.owner?.email}
+
+
+
+ {#if currentAccount === shared.owner}
+
+ {/if}
+
+ {/if}
+ {/each}
+ {/if}
+
+
+ {/if}
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/StatsBar.svelte b/apps/uload/apps/web/src/lib/components/StatsBar.svelte
new file mode 100644
index 000000000..03e5ffaa8
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/StatsBar.svelte
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+ {#each statItems as stat}
+
+
+
+
+
+
+
+
+
+ {formatNumber(displayStats[stat.key] || 0)}
+
+
+ {stat.label}
+
+
+
+ {/each}
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/TagBadge.svelte b/apps/uload/apps/web/src/lib/components/TagBadge.svelte
new file mode 100644
index 000000000..17f3c44d3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/TagBadge.svelte
@@ -0,0 +1,75 @@
+
+
+{#if tag && tag.name}
+
+ {#if tag.icon && tag.icon.trim()}
+ {tag.icon}
+ {/if}
+ {tag.name}
+ {#if removable}
+
+
+
+
+
+ {/if}
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/TagCard.svelte b/apps/uload/apps/web/src/lib/components/TagCard.svelte
new file mode 100644
index 000000000..63857b0f2
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/TagCard.svelte
@@ -0,0 +1,175 @@
+
+
+
+ {#if editingTag}
+
+ {:else}
+
+
+
+
+
+
+
+
+
{tag.linkCount || 0} links
+
+ Used in {tag.linkCount || 0} links
+
+
+
•
+
+
+
{tag.totalClicks || 0} clicks
+
+ Total clicks: {tag.totalClicks || 0}
+
+
+
•
+
+
+
{tag.usage_count || 0} uses
+
+ Usage count: {tag.usage_count || 0}
+
+
+ {#if tag.is_public}
+
•
+
Public
+ {:else}
+
•
+
Private
+ {/if}
+
+
+
',
+ color: '#9333ea',
+ action: startEdit,
+ },
+ {
+ label: 'View Links',
+ icon: ' ',
+ color: '#2563eb',
+ href: `/my/links?tag=${tag.name}`,
+ },
+ {
+ label: tag.is_public ? 'Make Private' : 'Make Public',
+ icon: tag.is_public
+ ? ' '
+ : ' ',
+ color: '#ea580c',
+ type: 'form',
+ formAction: '?/togglePublic',
+ formData: { id: tag.id, is_public: String(!tag.is_public) },
+ },
+ {
+ divider: true,
+ },
+ {
+ label: 'Delete',
+ icon: ' ',
+ color: '#dc2626',
+ type: 'form',
+ formAction: '?/delete',
+ formData: { id: tag.id },
+ enhanceOptions: () => {
+ return async ({ update }) => {
+ if (confirm(`Are you sure you want to delete the tag "${tag.name}"?`)) {
+ await update();
+ }
+ };
+ },
+ },
+ ]}
+ buttonText="Actions"
+ size="sm"
+ />
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/TagList.svelte b/apps/uload/apps/web/src/lib/components/TagList.svelte
new file mode 100644
index 000000000..74e69ac47
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/TagList.svelte
@@ -0,0 +1,100 @@
+
+
+{#if tags && tags.length > 0}
+ {#if viewMode === 'stats'}
+
+ {:else if viewMode === 'cards'}
+
+ {#each tags as tag}
+
+ {/each}
+
+ {:else}
+
+
+
+ Your Tags ({tags.length} total)
+
+
+
+
+ {#if isSelectMode}
{/if}
+
Tag Name
+
Links
+
Clicks
+
Uses
+
Status
+
Actions
+
+
+
+ {#if isSelectMode}
{/if}
+
Tag Name
+
Links
+
Clicks
+
Actions
+
+
+
+ {#each tags as tag}
+ onToggleSelect(tag.id)}
+ />
+ {/each}
+
+
+ {/if}
+{:else}
+
+
No tags yet. Create your first tag to organize your links!
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/TagListItem.svelte b/apps/uload/apps/web/src/lib/components/TagListItem.svelte
new file mode 100644
index 000000000..81ed12ed9
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/TagListItem.svelte
@@ -0,0 +1,413 @@
+
+
+
+
+ {#if isSelectMode}
+
+
+
+ {/if}
+ {#if editingTag}
+
+ {:else}
+
+
+
+
+
+
+
+
{tag.linkCount || 0} links
+
+ Used in {tag.linkCount || 0} links
+
+
+
+
+
+
+
{tag.totalClicks || 0} clicks
+
+ Total clicks from all links: {tag.totalClicks || 0}
+
+
+
+
+
+
{tag.usage_count || 0} uses
+
+ Internal usage counter
+
+
+
+
+
+ {#if tag.is_public}
+ Public
+ {:else}
+ Private
+ {/if}
+
+
+
+
+
+ Edit
+
+
+
+ {/if}
+
+
+
+
+ {#if isSelectMode}
+
+
+
+ {/if}
+ {#if editingTag}
+
+ {:else}
+
+
+
+
+ {tag.linkCount || 0} links
+
+
+
+ {tag.totalClicks || 0}
+
+
+
+ Edit
+
+
+
+ {/if}
+
+
+
+
+ {#if editingTag}
+
+ {:else}
+
+ {#if isSelectMode}
+
+
+
+ Select
+
+
+ {/if}
+
+
+ {#if tag.is_public}
+ Public
+ {:else}
+ Private
+ {/if}
+
+
+
+
+ {tag.linkCount || 0} links
+
+
+ {tag.totalClicks || 0} clicks
+
+ {tag.usage_count || 0} uses
+
+
+
+ {#if !isSelectMode}
+
+
+ Edit
+
+
+
+ {/if}
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/TagSelector.svelte b/apps/uload/apps/web/src/lib/components/TagSelector.svelte
new file mode 100644
index 000000000..72ff2baaf
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/TagSelector.svelte
@@ -0,0 +1,207 @@
+
+
+
+ {#if selectedTags.length > 0}
+
+ {#each selectedTags as tag}
+ removeTag(tag)} />
+ {/each}
+
+ {/if}
+
+
+
+
+ {#if isDropdownOpen && (filteredTags.length > 0 || canCreateNewTag || isCreatingTag)}
+
+ {#if isCreatingTag}
+
+
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ createNewTag();
+ } else if (e.key === 'Escape') {
+ isCreatingTag = false;
+ newTagName = '';
+ }
+ }}
+ />
+
+ Add
+
+ {
+ isCreatingTag = false;
+ newTagName = '';
+ }}
+ class="rounded bg-gray-200 px-3 py-1 text-sm text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
+ >
+ Cancel
+
+
+
+ {/if}
+
+ {#each filteredTags as tag}
+
toggleTag(tag)}
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
+ >
+
+
+ {/each}
+
+ {#if canCreateNewTag && !isCreatingTag}
+
{
+ isCreatingTag = true;
+ newTagName = searchQuery;
+ }}
+ class="flex w-full items-center gap-2 border-t border-theme-border px-3 py-2 text-left text-sm text-theme-accent hover:bg-gray-100 dark:hover:bg-gray-700"
+ >
+
+
+
+ Create "{searchQuery}"
+
+ {/if}
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/TagStats.svelte b/apps/uload/apps/web/src/lib/components/TagStats.svelte
new file mode 100644
index 000000000..84c793a2a
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/TagStats.svelte
@@ -0,0 +1,290 @@
+
+
+
+
+
+
+
+
+
Gesamt Tags
+
{totalTags}
+
+
+
+
+
+
+
+
+
+
+
Gesamt Klicks
+
{formatNumber(totalClicks)}
+
+
+
+
+
+
+
+
+
+
+
Ø Links/Tag
+
{averageLinksPerTag}
+
+
+
+
+
+
+
+
+
Top Tag
+ {#if mostClickedTag}
+
{mostClickedTag.name}
+
+ {formatNumber(mostClickedTag.totalClicks || 0)} Klicks
+
+ {:else}
+
-
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Top 10 Tags nach Klicks
+
+
+
+ {#each topTagsByClicks as tag, index}
+
+
+ {index + 1}
+
+
+
+
+ {tag.name}
+
+
+ {formatNumber(tag.totalClicks || 0)}
+
+
+
+
+
+ {/each}
+ {#if topTagsByClicks.length === 0}
+
Keine Daten verfügbar
+ {/if}
+
+
+
+
+
+
+
Top 10 Tags nach Links
+
+
+
+ {#each topTagsByLinks as tag, index}
+
+
+ {index + 1}
+
+
+
+
+ {tag.name}
+
+
+ {tag.linkCount || 0} Links
+
+
+
+
+
+ {/each}
+ {#if topTagsByLinks.length === 0}
+
Keine Daten verfügbar
+ {/if}
+
+
+
+
+
+
+
+
Detaillierte Tag-Statistiken
+
+
+
+
+
+
+ Tag
+
+
+ Links
+
+
+ Klicks
+
+
+ CTR
+
+
+ Verwendungen
+
+
+ Status
+
+
+ Erstellt
+
+
+
+
+ {#each tags as tag}
+
+
+
+
+
+ {tag.linkCount || 0}
+
+
+ {formatNumber(tag.totalClicks || 0)}
+
+
+
+ {calculateCTR(tag)}
+
+
+
+ {tag.usage_count || 0}
+
+
+ {#if tag.is_public}
+
+ Öffentlich
+
+ {:else}
+
+ Privat
+
+ {/if}
+
+
+ {new Date(tag.created).toLocaleDateString('de-DE')}
+
+
+ {/each}
+
+
+ {#if tags.length === 0}
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte b/apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte
new file mode 100644
index 000000000..0104c1721
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/ThemeDropdown.svelte
@@ -0,0 +1,160 @@
+
+
+
+
toggleDropdown(e)}
+ class="rounded-lg p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ aria-label="Theme settings"
+ title="Theme settings"
+ >
+
+
+
+
+
+ {#if showDropdown}
+
+
+
+
+ Dark Mode
+ toggleDarkMode(e)}
+ class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {themeStore.isDark
+ ? 'bg-theme-accent'
+ : 'bg-theme-border'}"
+ aria-label="Toggle dark mode"
+ >
+
+
+
+
+
+
+
+
Choose Theme
+
+ {#each Object.values(themes) as theme}
+
selectTheme(theme.id, e)}
+ class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover {themeStore.preset ===
+ theme.id
+ ? 'bg-theme-surface-hover'
+ : ''}"
+ >
+
+
+
+
+ {theme.name}
+ {theme.description}
+
+
+ {#if themeStore.preset === theme.id}
+
+
+
+ {/if}
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/UpgradeButton.svelte b/apps/uload/apps/web/src/lib/components/UpgradeButton.svelte
new file mode 100644
index 000000000..8baa1eec8
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/UpgradeButton.svelte
@@ -0,0 +1,75 @@
+
+
+
+ {#if loading}
+
+ Lädt...
+ {:else}
+ Upgrade für {priceDisplay[priceType]}
+ {/if}
+
+
+{#if error}
+ {error}
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/ViewToggle.svelte b/apps/uload/apps/web/src/lib/components/ViewToggle.svelte
new file mode 100644
index 000000000..96155c4f0
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/ViewToggle.svelte
@@ -0,0 +1,62 @@
+
+
+
+ onViewChange('cards')}
+ class="flex items-center gap-2 rounded px-3 py-1.5 text-sm font-medium transition-colors {currentView ===
+ 'cards'
+ ? 'bg-theme-primary text-white'
+ : 'text-theme-text hover:bg-theme-surface-hover'}"
+ title="Card View"
+ >
+
+
+
+
+
+
+ Cards
+
+ onViewChange('list')}
+ class="flex items-center gap-2 rounded px-3 py-1.5 text-sm font-medium transition-colors {currentView ===
+ 'list'
+ ? 'bg-theme-primary text-white'
+ : 'text-theme-text hover:bg-theme-surface-hover'}"
+ title="List View"
+ >
+
+
+
+
+
+ List
+
+ {#if showStats}
+ onViewChange('stats')}
+ class="flex items-center gap-2 rounded px-3 py-1.5 text-sm font-medium transition-colors {currentView ===
+ 'stats'
+ ? 'bg-theme-primary text-white'
+ : 'text-theme-text hover:bg-theme-surface-hover'}"
+ title="Stats View"
+ >
+
+
+
+
+
+
+
+ Stats
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte b/apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte
new file mode 100644
index 000000000..fbc46b717
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/WorkspaceSwitcher.svelte
@@ -0,0 +1,202 @@
+
+
+
+
+ {#if activeWorkspaceData || current}
+ {#if (activeWorkspaceData || current).type === 'team'}
+
+ {:else}
+
+ {/if}
+
+ {getWorkspaceDisplayName(activeWorkspaceData || current)}
+
+
+ {:else}
+
+ Select Workspace
+
+ {/if}
+
+
+ {#if showDropdown}
+
+
+ {#if workspacesState.personalWorkspace}
+
+
+ Personal Workspace
+
+
+ workspacesState.personalWorkspace &&
+ switchToWorkspace(workspacesState.personalWorkspace.id)}
+ class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+ {getWorkspaceDisplayName(workspacesState.personalWorkspace)}
+
+
Your personal workspace
+
+ {#if activeWorkspaceId === workspacesState.personalWorkspace.id}
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if workspacesState.teamWorkspaces && workspacesState.teamWorkspaces.length > 0}
+
+
+ Team Workspaces
+
+ {#each workspacesState.teamWorkspaces as workspace}
+
switchToWorkspace(workspace.id)}
+ class="group relative flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+ {getWorkspaceDisplayName(workspace)}
+
+ {#if workspace.description}
+
+ {workspace.description}
+
+ {/if}
+
+ {#if activeWorkspaceId === workspace.id}
+
+ {/if}
+
+ {/each}
+
+ {:else}
+
+
+
No team workspaces yet
+
+ Create or join a team workspace to collaborate
+
+
+ {/if}
+
+
+
+
+
+ Create Workspace
+
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte b/apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte
new file mode 100644
index 000000000..f98608148
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/blog/BlogCard.svelte
@@ -0,0 +1,146 @@
+
+
+ (isHovered = true)}
+ onmouseleave={() => (isHovered = false)}
+>
+ {#if post.image && viewMode === 'cards'}
+
+
+ {#if featured}
+
+
+ Featured
+
+
+ {/if}
+
+ {/if}
+
+ {#if post.image && viewMode === 'list'}
+
+
+ {#if featured}
+
+
+ Featured
+
+
+ {/if}
+
+ {/if}
+
+
+ {#if featured && !post.image}
+
+ Featured
+
+ {/if}
+
+
+
+
+ {post.excerpt}
+
+
+
+
+ {formattedDate}
+
+
+
+
+
+ {readingTimeText}
+
+
+
+ {#if post.tags.length > 0}
+
+ {#each post.tags.slice(0, 3) as tag}
+
+ #{tag}
+
+ {/each}
+ {#if post.tags.length > 3}
+
+ +{post.tags.length - 3}
+
+ {/if}
+
+ {/if}
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte b/apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte
new file mode 100644
index 000000000..9a84752a7
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/BaseCard.svelte
@@ -0,0 +1,73 @@
+
+
+
+ {@render children()}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte
new file mode 100644
index 000000000..3dec0f4dc
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/CardEditor.svelte
@@ -0,0 +1,541 @@
+
+
+
+
+
+
+
+
+
+ (activeTab = 'config')}
+ >
+ Configuration
+
+ (activeTab = 'metadata')}
+ >
+ Metadata
+
+ (activeTab = 'preview')}
+ >
+ Preview
+
+
+
+
+
+ {#if activeTab === 'config'}
+
+
+ Mode
+ handleModeChange(e.currentTarget.value)}
+ class="select"
+ >
+ {#each modes as mode}
+
+ {mode.label} - {mode.description}
+
+ {/each}
+
+
+
+
+ {#if isBeginnerCard(editingCard.config)}
+
+
Modules
+
+
+ {#if editingCard.config.modules.length > 0}
+
+ {#each editingCard.config.modules as module (module.id)}
+ {
+ if (!isBeginnerCard(editingCard.config)) return;
+ const index = editingCard.config.modules.findIndex((m) => m.id === module.id);
+ if (index >= 0) {
+ editingCard.config.modules[index] = updated;
+ }
+ }}
+ onRemove={() => removeModule(module.id)}
+ />
+ {/each}
+
+ {:else}
+
No modules yet. Add one below.
+ {/if}
+
+
+
+ addModule('header')} class="btn btn-sm">+ Header
+ addModule('content')} class="btn btn-sm">+ Content
+ addModule('links')} class="btn btn-sm">+ Links
+ addModule('media')} class="btn btn-sm">+ Media
+ addModule('stats')} class="btn btn-sm">+ Stats
+ addModule('footer')} class="btn btn-sm">+ Footer
+
+
+ {:else if isAdvancedCard(editingCard.config)}
+
+ {:else if isExpertCard(editingCard.config)}
+
+ {/if}
+ {:else if activeTab === 'metadata'}
+
+ {:else if activeTab === 'preview'}
+
+
+
+
Preview coming soon...
+
+
+ {/if}
+
+
+
+ {#if validationErrors.length > 0}
+
+
Validation Errors:
+
+ {#each validationErrors as error}
+ {error}
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte b/apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte
new file mode 100644
index 000000000..f35b5132f
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/CardRenderer.svelte
@@ -0,0 +1,285 @@
+
+
+
+ {#if editable}
+
+ {/if}
+
+ {#if isBeginnerCard(card.config)}
+
+ {:else if isAdvancedCard(card.config)}
+
+ {:else if isExpertCard(card.config)}
+
+ {:else}
+
+
+
+
+
Unknown card mode: {card.config.mode}
+
+ {/if}
+
+ {#if (showMetadata || !editable) && getMetadata()?.name}
+
+ {getMetadata()?.name}
+ {#if getMetadata()?.version}
+ v{getMetadata()?.version}
+ {/if}
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte b/apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte
new file mode 100644
index 000000000..24489d202
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/CustomCard.svelte
@@ -0,0 +1,156 @@
+
+
+
+ {#if error}
+
+ {:else if isLoading}
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte b/apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte
new file mode 100644
index 000000000..56fc14149
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/ModularCard.svelte
@@ -0,0 +1,236 @@
+
+
+
+ {#each sortedModules() as module (module.id)}
+ {#if isModuleVisible(module) && moduleComponents[module.type]}
+
+ handleModuleEvent(module.id, event, data)}
+ />
+
+ {/if}
+ {/each}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte b/apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte
new file mode 100644
index 000000000..23bdffd8e
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/ProfileCardItem.svelte
@@ -0,0 +1,149 @@
+
+
+ onDragStart(e, index)}
+ ondragover={(e) => onDragOver(e, index)}
+ ondragleave={onDragLeave}
+ ondrop={(e) => onDrop(e, index)}
+ ondragend={onDragEnd}
+ class="group relative cursor-move transition-all {dropTargetIndex === index
+ ? 'scale-105 opacity-50'
+ : ''}"
+>
+
+
+ {index + 1}
+
+
+
+
+
+
+
+ {card.metadata?.name || `Card ${index + 1}`}
+
+
+ Aspect: {card.constraints?.aspectRatio || 'auto'}
+
+
+
+
+
+
+
+
+
+
+
+ onToggleProfileDisplay(card)}
+ class="rounded border-theme-border"
+ />
+ Show on Profile
+
+ {#if card.visibility !== 'public' && card.page === 'profile'}
+ ⚠️ Set to public to display
+ {/if}
+
+
+
+
+ onEdit(card)}
+ class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded px-3 py-1.5 text-sm font-medium text-theme-primary transition"
+ >
+ Edit Card
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte b/apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte
new file mode 100644
index 000000000..f443854ee
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/SafeCardRenderer.svelte
@@ -0,0 +1,234 @@
+
+
+
+ {#if cardData?.type === 'modular'}
+
+
+
+ {#if headerModule}
+
+
+ {#if headerModule.props?.avatar}
+
+
{
+ e.currentTarget.style.display = 'none';
+ if (e.currentTarget.nextElementSibling) {
+ e.currentTarget.nextElementSibling.style.display = 'flex';
+ }
+ }}
+ />
+
+ {(headerModule.props?.title || 'U')[0].toUpperCase()}
+
+
+ {:else if headerModule.props?.title}
+
+ {headerModule.props.title[0].toUpperCase()}
+
+ {/if}
+
+
+ {#if headerModule.props?.title}
+
{headerModule.props.title}
+ {/if}
+ {#if headerModule.props?.subtitle}
+
{headerModule.props.subtitle}
+ {/if}
+
+ {/if}
+
+
+ {#if linksModule?.props?.links && linksModule.props.links.length > 0}
+
+ {#each linksModule.props.links as link}
+
+ {#if link.icon}
+
{link.icon}
+ {/if}
+
+
+ {link.title || link.original_url}
+
+ {#if link.description}
+
{link.description}
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+ {:else if cardData?.type === 'template'}
+
+
+
Template Card
+
Template: {cardData.template}
+
+ {:else if cardData?.type === 'custom'}
+
+
+
Custom Card
+
Custom HTML/CSS Card
+
+ {:else}
+
+
+
+
+ {card.title || card.metadata?.name || 'Unnamed Card'}
+
+ {#if card.subtitle}
+
{card.subtitle}
+ {/if}
+
+ {/if}
+
+
+ {#if showMetadata}
+
+
+ {card.metadata?.name || 'Unnamed Card'}
+ {card.config?.mode || 'unknown'} mode
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte b/apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte
new file mode 100644
index 000000000..ddfb059a5
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/TemplateCard.svelte
@@ -0,0 +1,190 @@
+
+
+
+ {#if template}
+
+ {:else}
+
+
+
+
+
No template provided
+
+ {/if}
+
+
+{#if variables.length > 0 && import.meta.env.DEV}
+
+
+ Template Variables ({variables.length})
+
+ {#each variables as variable}
+
+ {variable.name}
+ {variable.type}
+
+ {values?.[variable.name] || 'undefined'}
+
+
+ {/each}
+
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte
new file mode 100644
index 000000000..065902101
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/editor/CodeEditor.svelte
@@ -0,0 +1,102 @@
+
+
+
+
+
HTML
+
+
+
+
+
CSS
+
+
+
+
+
+ 💡 Tip: Your HTML and CSS will be sanitized for security. Scripts and dangerous
+ patterns will be removed.
+
+
📏 Limits: HTML max 100KB, CSS max 50KB
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte
new file mode 100644
index 000000000..6468b0ffb
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/editor/ModuleEditor.svelte
@@ -0,0 +1,363 @@
+
+
+
+
+
+ {#if expanded}
+
+ {#if module.type === 'header'}
+
+ Title
+ updateProp('title', e.currentTarget.value)}
+ placeholder="Enter title"
+ />
+
+
+ Subtitle
+ updateProp('subtitle', e.currentTarget.value)}
+ placeholder="Enter subtitle"
+ />
+
+ {:else if module.type === 'content'}
+
+ Text
+
+
+ {:else if module.type === 'links'}
+
+ Links (JSON)
+
+
+
+ Style
+ updateProp('style', e.currentTarget.value)}
+ >
+ Button
+ List
+ Card
+
+
+ {:else if module.type === 'media'}
+
+ Type
+ updateProp('type', e.currentTarget.value)}
+ >
+ Image
+ Video
+ QR Code
+
+
+ {#if module.props.type === 'image'}
+
+ Image URL
+ updateProp('src', e.currentTarget.value)}
+ placeholder="https://example.com/image.jpg"
+ />
+
+
+ Alt Text
+ updateProp('alt', e.currentTarget.value)}
+ placeholder="Image description"
+ />
+
+ {:else if module.props.type === 'qr'}
+
+ QR Data
+ updateProp('qrData', e.currentTarget.value)}
+ placeholder="https://example.com"
+ />
+
+ {/if}
+ {:else if module.type === 'stats'}
+
+ Stats (JSON)
+
+
+ {:else if module.type === 'footer'}
+
+ Text
+ updateProp('text', e.currentTarget.value)}
+ placeholder="Footer text"
+ />
+
+
+ Copyright
+ updateProp('copyright', e.currentTarget.value)}
+ placeholder="© 2024"
+ />
+
+ {:else}
+
+ Custom Props (JSON)
+
+
+ {/if}
+
+
+ Visibility
+ onUpdate({ ...module, visibility: e.currentTarget.value as any })}
+ >
+ Always
+ Desktop Only
+ Mobile Only
+
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte b/apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte
new file mode 100644
index 000000000..986a51369
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/editor/TemplateEditor.svelte
@@ -0,0 +1,202 @@
+
+
+
+
+
HTML Template
+
Use {'{{variable}}'} syntax for dynamic content
+
+
+
+
+
CSS Styles
+
+
+
+ {#if variables.length > 0}
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte
new file mode 100644
index 000000000..ce5271376
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/ActionsModule.svelte
@@ -0,0 +1,55 @@
+
+
+
+ {#each actions as action}
+ handleClick(action)}
+ class={getButtonClass(action.variant)}
+ disabled={action.disabled}
+ >
+ {#if action.icon}
+ {action.icon}
+ {/if}
+ {action.label}
+
+ {/each}
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte
new file mode 100644
index 000000000..67ac6c666
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/ContentModule.svelte
@@ -0,0 +1,72 @@
+
+
+
+ {#if html}
+ {@html html}
+ {:else if text}
+
{text}
+ {:else if items.length > 0}
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte
new file mode 100644
index 000000000..d5d3d5cb8
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/FooterModule.svelte
@@ -0,0 +1,53 @@
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte
new file mode 100644
index 000000000..35c69ab94
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/HeaderModule.svelte
@@ -0,0 +1,60 @@
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte
new file mode 100644
index 000000000..f8537faa3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/LinksModule.svelte
@@ -0,0 +1,125 @@
+
+
+
+ {#each links as link}
+
handleClick(link.href)}
+ class={getLinkStyleClass()}
+ disabled={link.disabled}
+ title={link.description || link.label}
+ >
+
+ {#if showIcon && link.icon}
+
{link.icon}
+ {/if}
+
+
+
+ {link.label}
+
+ {#if showDescription && link.description}
+
+ {link.description}
+
+ {/if}
+
+
+
+
+
+
+
+ {/each}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte
new file mode 100644
index 000000000..0064d2861
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/MediaModule.svelte
@@ -0,0 +1,44 @@
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte b/apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte
new file mode 100644
index 000000000..13e74699b
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/modules/StatsModule.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {#each stats as stat}
+
+ {#if stat.icon}
+
+ {stat.icon}
+
+ {/if}
+
+
+
+ {stat.value}
+ {#if stat.change}
+
+ {stat.change > 0 ? '↑' : '↓'}
+ {Math.abs(stat.change)}%
+
+ {/if}
+
+
{stat.label}
+
+
+ {/each}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/cards/types.ts b/apps/uload/apps/web/src/lib/components/cards/types.ts
new file mode 100644
index 000000000..3f34cd6a2
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/cards/types.ts
@@ -0,0 +1,275 @@
+// ============================================
+// SIMPLIFIED CARD SYSTEM V2 - Using Discriminated Unions
+// ============================================
+
+// Base Types
+export type RenderMode = 'beginner' | 'advanced' | 'expert';
+
+// Card Metadata
+export interface CardMetadata {
+ name?: string;
+ description?: string;
+ author?: string;
+ version?: string;
+ created?: string;
+ updated?: string;
+ tags?: string[];
+ isActive?: boolean;
+ isPublic?: boolean;
+}
+
+// Card Constraints
+export interface CardConstraints {
+ aspectRatio?: string;
+ maxWidth?: string;
+ minHeight?: string;
+ maxHeight?: string;
+ maxModules?: number;
+ maxHTMLSize?: number;
+ maxCSSSize?: number;
+ preventScripts?: boolean;
+}
+
+// Theme Configuration
+export interface Theme {
+ id?: string;
+ name?: string;
+ colors?: Record;
+ typography?: {
+ fontFamily?: string;
+ fontSize?: Record;
+ fontWeight?: Record;
+ lineHeight?: Record;
+ };
+ spacing?: Record;
+ borderRadius?: Record;
+ shadows?: Record;
+}
+
+// Module Definition
+export interface Module {
+ id: string;
+ type: 'header' | 'content' | 'footer' | 'media' | 'stats' | 'actions' | 'links' | 'custom';
+ props: Record;
+ order: number;
+ visibility?: 'always' | 'desktop' | 'mobile';
+ grid?: {
+ col?: number;
+ row?: number;
+ colSpan?: number;
+ rowSpan?: number;
+ };
+ className?: string;
+}
+
+// Template Variable
+export interface TemplateVariable {
+ name: string;
+ type: 'text' | 'number' | 'image' | 'link' | 'list' | 'boolean' | 'color';
+ label: string;
+ default?: any;
+ required?: boolean;
+ placeholder?: string;
+ options?: Array<{ label: string; value: any }>;
+}
+
+// ============================================
+// DISCRIMINATED UNION FOR CARD CONFIGURATIONS
+// ============================================
+
+export type CardConfig =
+ | {
+ mode: 'beginner';
+ modules: Module[];
+ theme?: Theme;
+ layout?: {
+ columns?: number;
+ gap?: string;
+ padding?: string;
+ };
+ animations?: {
+ hover?: boolean;
+ entrance?: 'fade' | 'slide' | 'scale' | 'none';
+ };
+ }
+ | {
+ mode: 'advanced';
+ template: string;
+ css?: string;
+ variables: TemplateVariable[];
+ values: Record;
+ }
+ | {
+ mode: 'expert';
+ html: string;
+ css: string;
+ javascript?: string;
+ };
+
+// Main Card Interface (Consolidated from UnifiedCard)
+export interface Card {
+ id?: string;
+ user_id?: string;
+ type?: 'user' | 'template' | 'system';
+ template_id?: string;
+ source?: 'created' | 'duplicated' | 'imported' | 'migrated';
+ config: CardConfig;
+ metadata?: CardMetadata;
+ constraints?: CardConstraints;
+ page?: string;
+ position?: number;
+ visibility?: 'private' | 'public' | 'unlisted';
+ variant?: 'default' | 'compact' | 'hero' | 'minimal' | 'glass' | 'gradient' | string;
+ tags?: string[];
+ category?: string;
+ usage_count?: number;
+ likes_count?: number;
+ is_featured?: boolean;
+ allow_duplication?: boolean;
+ created?: string;
+ updated?: string;
+}
+
+// Database Card Interface
+export interface DBCard {
+ id: string;
+ user_id: string;
+ config: string; // JSON stringified CardConfig
+ metadata: string; // JSON stringified CardMetadata
+ constraints: string; // JSON stringified CardConstraints
+ variant?: string;
+ created: string;
+ updated: string;
+}
+
+// ============================================
+// MODULE PROP TYPES (Simplified)
+// ============================================
+
+export interface ModuleProps {
+ header: {
+ title?: string;
+ subtitle?: string;
+ avatar?: string;
+ badge?: string;
+ icon?: string;
+ };
+ content: {
+ text?: string;
+ html?: string;
+ truncate?: boolean;
+ maxLines?: number;
+ };
+ links: {
+ links: Array<{
+ label: string;
+ href: string;
+ icon?: string;
+ description?: string;
+ }>;
+ style?: 'button' | 'list' | 'card';
+ columns?: 1 | 2;
+ target?: '_blank' | '_self';
+ };
+ media: {
+ type: 'image' | 'video' | 'qr';
+ src?: string;
+ alt?: string;
+ aspectRatio?: string;
+ qrData?: string;
+ };
+ stats: {
+ stats: Array<{
+ label: string;
+ value: string | number;
+ change?: number;
+ icon?: string;
+ }>;
+ layout?: 'grid' | 'list';
+ };
+ actions: {
+ actions: Array<{
+ label: string;
+ href?: string;
+ onClick?: () => void;
+ variant?: 'primary' | 'secondary' | 'ghost';
+ icon?: string;
+ }>;
+ layout?: 'horizontal' | 'vertical';
+ };
+ footer: {
+ text?: string;
+ links?: Array<{
+ label: string;
+ href: string;
+ }>;
+ copyright?: string;
+ };
+ custom: {
+ html: string;
+ css?: string;
+ };
+}
+
+// Type Guards
+export function isBeginnerCard(
+ config: CardConfig
+): config is Extract {
+ return config.mode === 'beginner';
+}
+
+export function isAdvancedCard(
+ config: CardConfig
+): config is Extract {
+ return config.mode === 'advanced';
+}
+
+export function isExpertCard(
+ config: CardConfig
+): config is Extract {
+ return config.mode === 'expert';
+}
+
+// Conversion Types
+export interface CardConverter {
+ toModular(config: CardConfig): Promise>;
+ toTemplate(config: CardConfig): Promise>;
+ toCustom(config: CardConfig): Promise>;
+}
+
+// Validation Result
+export interface ValidationResult {
+ valid: boolean;
+ errors?: Array<{
+ field: string;
+ message: string;
+ }>;
+}
+
+// Card Events
+export interface CardEvent {
+ type: 'created' | 'updated' | 'deleted' | 'converted';
+ cardId: string;
+ timestamp: number;
+ data?: any;
+}
+
+// Card Store Actions
+export interface CardActions {
+ create(config: CardConfig, metadata?: CardMetadata): Promise;
+ update(id: string, updates: Partial): Promise;
+ delete(id: string): Promise;
+ convert(id: string, targetMode: RenderMode): Promise;
+ duplicate(id: string): Promise;
+ validate(card: Card): ValidationResult;
+}
+
+// Export all types
+export type { Theme as ThemeConfig }; // Alias for backward compatibility
+
+// Legacy aliases for backward compatibility
+export type UnifiedCard = Card;
+export type ModularConfig = Extract;
+export type TemplateConfig = Extract;
+export type CustomHTMLConfig = Extract;
+export type { Module as ModuleConfig };
diff --git a/apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte b/apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte
new file mode 100644
index 000000000..f38c98325
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/gdpr/CookieBanner.svelte
@@ -0,0 +1,328 @@
+
+
+{#if showBanner}
+
+
+ {#if !showDetails}
+
+
+
+
+
+
+
+
+
+
+
+ Cookies & Datenschutz
+
+
+ Wir verwenden Cookies und ähnliche Technologien, um Ihnen die bestmögliche
+ Erfahrung zu bieten. Einige sind technisch notwendig, andere helfen uns die
+ Website zu verbessern und zu analysieren.
+
+
+
+
+
+
+
+
+
+
+
+ Nur notwendige
+
+
+ Anpassen
+
+
+ Alle akzeptieren
+
+
+
+ {:else}
+
+
+
+
+
+ Cookie-Einstellungen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Notwendige Cookies
+
+ Technisch erforderlich für die Grundfunktionen der Website
+
+
+
+
+
+ Speichern von Login-Status, Spracheinstellungen und technischen Präferenzen
+
+
+
+
+
+
+
+
Analytics Cookies
+
+ Helfen uns die Website zu verbessern
+
+
+
handleCustomChange('analytics', !customConsent.analytics)}
+ class="relative"
+ >
+
+
+
+
+ Anonyme Nutzungsstatistiken, Seitenaufrufe und Klick-Verhalten
+
+
+
+
+
+
+
+
Marketing Cookies
+
+ Für personalisierte Inhalte und Werbung
+
+
+
handleCustomChange('marketing', !customConsent.marketing)}
+ class="relative"
+ >
+
+
+
+
+ Newsletter-Präferenzen und zielgerichtete Kommunikation
+
+
+
+
+
+
+
+
Präferenz Cookies
+
+ Speichern Ihre persönlichen Einstellungen
+
+
+
handleCustomChange('preferences', !customConsent.preferences)}
+ class="relative"
+ >
+
+
+
+
+ Theme-Einstellungen, Layout-Präferenzen und Benutzeroberfläche
+
+
+
+
+
+
+
+ Nur notwendige Cookies
+
+
+ Auswahl speichern
+
+
+ Alle akzeptieren
+
+
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte b/apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte
new file mode 100644
index 000000000..9f89390ab
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/BlogSection.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+
+
Insights & Wissen
+
+ Entdecken Sie Artikel über URL-Psychologie, Marketing-Strategien und Best Practices für
+ erfolgreiches Link-Management.
+
+
+
+ {#if formattedPosts.length > 0}
+
+ {#each formattedPosts as post}
+
+ {#if post.image}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {post.formattedDate}
+
+ •
+
+ {post.category}
+
+
+
+
+
+
+ {post.excerpt}
+
+
+
+ Weiterlesen
+
+
+
+
+
+
+ {/each}
+
+ {:else}
+
+
+ Bald verfügbar: Spannende Artikel über URL-Optimierung und digitales Marketing.
+
+
+ {/if}
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte b/apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte
new file mode 100644
index 000000000..f1ee97781
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/FeatureShowcase.svelte
@@ -0,0 +1,553 @@
+
+
+
+
+
+
+ Alle Features die du brauchst
+
+
+ Von Link-Verkürzung bis Team-Kollaboration - alles in einer Plattform vereint
+
+
+
+
+
+
+
(selectedFeature = 'links')}
+ class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
+ 'links'
+ ? 'bg-theme-primary text-white shadow-lg'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+
+
+
+ Smart Link Management
+
+
+ Kurze URLs mit erweiterten Features
+
+
+
+
+
(selectedFeature = 'cards')}
+ class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
+ 'cards'
+ ? 'bg-theme-primary text-white shadow-lg'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+
+
+
+ Profilkarten Builder
+
+
+ 3-Stufen Builder mit Live-Preview
+
+
+
+
+
(selectedFeature = 'analytics')}
+ class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
+ 'analytics'
+ ? 'bg-theme-primary text-white shadow-lg'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+
+
+
+ Professionelle Analytics
+
+
+ Echtzeit-Tracking und Insights
+
+
+
+
+
(selectedFeature = 'qr')}
+ class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
+ 'qr'
+ ? 'bg-theme-primary text-white shadow-lg'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+
+
+
+ QR-Code Generator
+
+
+ Anpassbare Designs und Farben
+
+
+
+
+
(selectedFeature = 'team')}
+ class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
+ 'team'
+ ? 'bg-theme-primary text-white shadow-lg'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+
+
+
+ Team Kollaboration
+
+
+ Workspaces und Berechtigungen
+
+
+
+
+
(selectedFeature = 'templates')}
+ class="group flex w-full items-center gap-4 rounded-lg px-6 py-4 text-left transition {selectedFeature ===
+ 'templates'
+ ? 'bg-theme-primary text-white shadow-lg'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+
+
+
+ Template Store
+
+
+ Vorgefertigte Designs und Layouts
+
+
+
+
+
+
+
+
+ {#if selectedFeature === 'links'}
+
+
Smart Link Features
+
+
+
+
+
Ablaufdatum festlegen
+
+
+
+
Click-Limits definieren
+
+
+
+
Passwortschutz aktivieren
+
+
+
+
Tags zur Organisation
+
+
+
+
+
+ Beispiel: ulo.ad/produkt-launch → 500 Clicks, läuft in 7 Tagen ab
+
+
+
+ {/if}
+
+ {#if selectedFeature === 'cards'}
+
+
3-Stufen Builder
+
+
+
👶 Anfänger
+
+ Einfache Vorlagen, schnell anpassbar
+
+
+
+
💪 Fortgeschritten
+
+ Drag & Drop Module, mehr Kontrolle
+
+
+
+
🚀 Experte
+
+ Volle Freiheit, eigener Code möglich
+
+
+
+
+ {/if}
+
+ {#if selectedFeature === 'analytics'}
+
+
Analytics Dashboard
+
+
+
+
+
+
Top Referrer
+
+ Instagram
+ 45%
+
+
+ Twitter
+ 28%
+
+
+ Direct
+ 27%
+
+
+
+
+ {/if}
+
+ {#if selectedFeature === 'qr'}
+
+
QR-Code Optionen
+
+
+
Formate:
+
+ PNG
+ SVG
+ JPG
+
+
+
+ {/if}
+
+ {#if selectedFeature === 'team'}
+
+
Team Workspace
+
+
+
+
+
+
Max Mustermann
+
Admin
+
+
+
Full Access
+
+
+
+
+
+
Anna Schmidt
+
Editor
+
+
+
Edit Links
+
+
+
+
+ {/if}
+
+ {#if selectedFeature === 'templates'}
+
+
Template Gallery
+
+
+ Alle Templates ansehen →
+
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte b/apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte
new file mode 100644
index 000000000..9e9efe077
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/HeroSection.svelte
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DSGVO-konform
+
+
+
+
+
+ Blitzschnell
+
+
+
+
+
+ 100% Sicher
+
+
+
+
+
+ More than links.
+
+ Your digital identity.
+
+
+
+
+ Der einzige Link-Shortener mit integriertem Profile-Builder. Erstelle kurze Links,
+ beeindruckende Profilkarten und manage alles im Team.
+
+
+
+
+
+
+
+
+
+ Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
+
+
+
+
+
+
+
+
+
+
Smart Links
+
+ Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz
+
+
Mehr erfahren →
+
+
+
+
+
+
Profile Cards
+
+ Beeindruckende Profilseiten mit Drag & Drop Builder
+
+
Templates ansehen →
+
+
+
+
+
+
Team Workspace
+
+ Gemeinsam Links verwalten mit granularen Berechtigungen
+
+
Für Teams →
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte b/apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte
new file mode 100644
index 000000000..d92e25ed6
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/PricingSection.svelte
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+ Transparente Preise, keine versteckten Kosten
+
+
+ Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar.
+
+
+
+
+ (billingCycle = 'monthly')}
+ class="rounded-md px-6 py-2 text-sm font-medium transition {billingCycle === 'monthly'
+ ? 'bg-theme-primary text-white'
+ : 'hover:text-theme-text/80 text-theme-text'}"
+ >
+ Monatlich
+
+ (billingCycle = 'yearly')}
+ class="relative rounded-md px-6 py-2 text-sm font-medium transition {billingCycle ===
+ 'yearly'
+ ? 'bg-theme-primary text-white'
+ : 'hover:text-theme-text/80 text-theme-text'}"
+ >
+ Jährlich
+
+ -17%
+
+
+
+
+
+
+
+ {#each plans as plan}
+
(hoveredPlan = plan.id)}
+ onmouseleave={() => (hoveredPlan = null)}
+ >
+ {#if plan.badge}
+
+
+ {plan.badge}
+
+
+ {/if}
+
+
+
{plan.name}
+
{plan.description}
+
+
+
+
+ {formatPrice(
+ billingCycle === 'monthly' ? plan.price.monthly : plan.price.yearly / 12
+ )}
+
+ /Monat
+
+ {#if billingCycle === 'yearly' && plan.price.yearly > 0}
+
+ Spare {getYearlySavings(plan.price.monthly, plan.price.yearly)}% jährlich
+
+ {/if}
+
+
+
+ {plan.cta}
+
+
+
+
+ Inklusive:
+
+ {#each plan.features as feature}
+
+ {/each}
+
+ {#if plan.limitations.length > 0}
+
+ {#each plan.limitations as limitation}
+
+ {/each}
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+
+
+
+
💳 Keine Kreditkarte erforderlich
+
+ Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst.
+
+
+
+
🔄 Jederzeit kündbar
+
+ Keine Vertragsbindung. Kündige monatlich ohne Probleme.
+
+
+
+
🚀 Sofort startklar
+
+ Nach der Anmeldung kannst du sofort alle Features nutzen.
+
+
+
+
+
+
+
+
+ Benötigst du eine maßgeschneiderte Lösung für dein Unternehmen?
+
+
+ Kontaktiere uns für Enterprise-Lösungen
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte b/apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte
new file mode 100644
index 000000000..a10130bd0
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/TargetAudience.svelte
@@ -0,0 +1,487 @@
+
+
+
+
+
+
+ Für jeden die richtige Lösung
+
+
+ Egal ob Creator, Team oder Unternehmen - wir haben die passenden Features für dich
+
+
+
+
+
+ (activeTab = 'creators')}
+ class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'creators'
+ ? 'bg-theme-primary text-white'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+ 📱 Creators & Influencer
+
+ (activeTab = 'teams')}
+ class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'teams'
+ ? 'bg-theme-primary text-white'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+ 💼 Teams & Agenturen
+
+ (activeTab = 'business')}
+ class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'business'
+ ? 'bg-theme-primary text-white'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+ 🏢 KMU & Startups
+
+ (activeTab = 'events')}
+ class="rounded-lg px-6 py-3 font-medium transition {activeTab === 'events'
+ ? 'bg-theme-primary text-white'
+ : 'hover:bg-theme-surface/80 bg-theme-surface text-theme-text'}"
+ >
+ 🎯 Events & Gastro
+
+
+
+
+
+ {#if activeTab === 'creators'}
+
+
+
Ein Link für alle deine Kanäle
+
+ Perfekt für Instagram, TikTok und YouTube. Erstelle beeindruckende Link-in-Bio Seiten,
+ tracke deine Klicks und verstehe deine Audience besser.
+
+
+
+
+
+
+ Anpassbare Profilseiten mit deinem Branding
+
+
+
+
+
+ QR-Codes für Offline-zu-Online Verbindung
+
+
+
+
+
+ Detaillierte Analytics zu Klicks und Herkunft
+
+
+
+
+
+ Social Media Icons und Integrationen
+
+
+
+
+
+
+
+
{}}
+ />
+
+
+
+
📱
+
Creator Profile
+
Coming Soon
+
+
+
+
+
+ {/if}
+
+ {#if activeTab === 'teams'}
+
+
+
Gemeinsam mehr erreichen
+
+ Perfekte Kollaboration für Marketing-Teams und Agenturen. Verwaltet Links gemeinsam,
+ teilt Analytics und arbeitet effizienter zusammen.
+
+
+
+
+
+
+ Team-Workspaces mit granularen Berechtigungen
+
+
+
+
+
+ Multi-Client Management für Agenturen
+
+
+
+
+
+ Gemeinsame Analytics und Reporting
+
+
+
+
+
+ Bulk-Operationen und CSV-Import
+
+
+
+
+
+
+
+
💼
+
Team Dashboard
+
10 Mitglieder • Unbegrenzte Links
+
+
+
+
+ {/if}
+
+ {#if activeTab === 'business'}
+
+
+
Professionelles Link-Management
+
+ Die kostengünstige Alternative zu Enterprise-Lösungen. Perfekt für KMUs und Startups,
+ die ihre digitale Präsenz professionell verwalten wollen.
+
+
+
+
+
+
+ Custom Domains für deine Marke (coming soon)
+
+
+
+
+
+ API-Zugang für Automatisierung
+
+
+
+
+
+ Erweiterte Analytics und Exporte
+
+
+
+
+
+ DSGVO-konform und hosted in Germany
+
+
+
+
+
+
+
+
🏢
+
Enterprise Ready
+
API • Custom Domain • SSO
+
+
+
+
+ {/if}
+
+ {#if activeTab === 'events'}
+
+
+
QR-Codes die funktionieren
+
+ Ideal für Restaurants, Events und Veranstaltungen. Erstelle QR-Codes für Speisekarten,
+ Event-Infos oder zeitlich begrenzte Aktionen.
+
+
+
+
+
+
+ QR-Codes in verschiedenen Farben und Formaten
+
+
+
+
+
+ Zeitlich begrenzte Links für Aktionen
+
+
+
+
+
+ Passwortgeschützte Inhalte für VIPs
+
+
+
+
+
+ Echtzeit-Updates ohne QR-Code Neudruck
+
+
+
+
+
+
+
+
🎯
+
Event QR-Codes
+
Dynamisch • Trackbar • Aktualisierbar
+
+
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte b/apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte
new file mode 100644
index 000000000..8fc58339f
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/Testimonials.svelte
@@ -0,0 +1,221 @@
+
+
+
+
+
+
Was Beta-Tester sagen
+
+ Erste Stimmen aus unserem exklusiven Beta-Programm
+
+
+
+
+
+ {#each stats as stat}
+
+
{stat.icon}
+
{stat.value}
+
{stat.label}
+
+ {/each}
+
+
+
+
+ {#each testimonials as testimonial}
+
+
+
+
+ {testimonial.avatar}
+
+
+
{testimonial.name}
+
{testimonial.role}
+
+
+
+ {testimonial.platform}
+
+
+
+
+ {#each Array(testimonial.rating) as _}
+
+
+
+ {/each}
+
+
+
+ "{testimonial.content}"
+
+
+ {/each}
+
+
+
+
+
+ Perfekt für diese Use Cases
+
+
+
+
+
+ 📱
+
+
+
Social Media Bio Links
+
Instagram & TikTok
+
+
+
+ Ein Link für alle deine Kanäle. Erstelle beeindruckende Profilkarten mit unserem Drag &
+ Drop Builder und tracke jeden Klick in Echtzeit.
+
+
+
+
+
+
+ 🍽️
+
+
+
Digitale Speisekarten
+
Restaurants & Cafés
+
+
+
+ QR-Codes die sich dynamisch aktualisieren lassen. Ändere Preise und Gerichte ohne neue
+ Codes drucken zu müssen.
+
+
+
+
+
+
+ 📊
+
+
+
Marketing Kampagnen
+
Performance Tracking
+
+
+
+ Erstelle trackbare Links für jede Kampagne. Unsere Analytics zeigen dir genau, welche
+ Kanäle am besten performen.
+
+
+
+
+
+
+ 🎯
+
+
+
Event Management
+
Tickets & Info-Links
+
+
+
+ Zeitlich begrenzte Links für Events. Setze Ablaufdaten und Passwörter für exklusive
+ Inhalte und VIP-Bereiche.
+
+
+
+
+
+
+
+
Sei einer der Ersten - starte jetzt kostenlos!
+
+ Beta-Zugang sichern
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte b/apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte
new file mode 100644
index 000000000..3a5557354
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/landing/TrustSignals.svelte
@@ -0,0 +1,270 @@
+
+
+
+
+
+
+ {#each trustBadges as badge}
+
+
{badge.icon}
+
{badge.title}
+
{badge.description}
+
+ {/each}
+
+
+
+
+
+
+ Sicherheit und Datenschutz an erster Stelle
+
+
+ Wir nehmen den Schutz deiner Daten ernst. Deshalb setzen wir auf höchste
+ Sicherheitsstandards.
+
+
+
+
+ {#each securityFeatures as feature}
+
+
+
+
+
+ {feature.title}
+
+
+ {#each feature.items as item}
+
+
+
+
+ {item}
+
+ {/each}
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
Premium Infrastructure
+
Hetzner Cloud Servers
+
+
+
+ Unsere Server laufen auf modernster Hetzner-Infrastruktur in deutschen Rechenzentren. Mit
+ automatischer Skalierung und Load Balancing gewährleisten wir beste Performance.
+
+
+
+ Frankfurt
+
+
+ Nürnberg
+
+
+ Falkenstein
+
+
+
+
+
+
+
+
+
Status & Monitoring
+
Transparente Verfügbarkeit
+
+
+
+ Wir überwachen unsere Systeme 24/7 und informieren proaktiv über Wartungen. Unser
+ öffentliches Status-Dashboard zeigt die aktuelle Verfügbarkeit aller Services.
+
+
+ Status-Seite besuchen
+
+
+
+
+
+
+
+
+
+
+ Zertifizierungen & Standards
+
+
+
+
🔐
+
SSL/TLS
+
Let's Encrypt
+
+
+
📋
+
DSGVO
+
EU Compliant
+
+
+
🛡️
+
ISO 27001
+
In Progress
+
+
+
✅
+
PCI DSS
+
Level 1
+
+
+
+
+
+
+
Fragen zur Sicherheit?
+
+ Unser Security-Team beantwortet gerne alle deine Fragen zum Datenschutz und zur Sicherheit.
+
+
+
+
+
+ security@ulo.ad
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCard.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCard.svelte
new file mode 100644
index 000000000..2fb2dd087
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkCard.svelte
@@ -0,0 +1,533 @@
+
+
+
+
+
+
+
+
+ {link.title || link.short_code}
+
+ {#if link.description}
+
{link.description}
+ {/if}
+
+
+
+
+
',
+ color: '#6366f1',
+ action: () => {
+ copyToClipboard(formatUrl(link.short_code), link.id, link.short_code);
+ },
+ },
+ {
+ label: 'QR Code',
+ icon: ' ',
+ color: '#10b981',
+ action: toggleQRCode,
+ },
+ {
+ label: 'Analytics',
+ href: `/my/analytics/${link.short_code}`,
+ icon: ' ',
+ color: '#2563eb',
+ },
+ {
+ label: 'Edit',
+ icon: ' ',
+ color: '#9333ea',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
+ },
+ },
+ {
+ label: link.is_active ? 'Deactivate' : 'Activate',
+ type: 'form',
+ formAction: '?/toggle',
+ formData: { id: link.id, is_active: String(link.is_active) },
+ icon: link.is_active
+ ? ' '
+ : ' ',
+ color: link.is_active ? '#ea580c' : '#16a34a',
+ },
+ {
+ divider: true,
+ },
+ {
+ label: 'Delete',
+ icon: ' ',
+ color: '#dc2626',
+ type: 'form',
+ formAction: '?/delete',
+ formData: { id: link.id },
+ enhanceOptions: () => {
+ return async ({ update, result, cancel }) => {
+ if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
+ cancel();
+ return;
+ }
+ await update();
+ if (result.type === 'success') {
+ trackEvent(EVENTS.LINK_DELETED, {
+ short_code: link.short_code,
+ });
+ toastMessages.linkDeleted();
+ }
+ };
+ },
+ },
+ ]}
+ buttonClass="!p-2"
+ />
+
+
+
+
+
+
+
+
{
+ const url = formatUrl(link.short_code);
+ copyToClipboard(url, `${link.id}-url`, link.short_code);
+ }}
+ class="rounded-md p-1.5 transition-colors hover:bg-white/50 dark:hover:bg-black/20"
+ title="Copy URL"
+ >
+
+ {#if copiedStates[`${link.id}-url`]}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if link.expand?.folder}
+
+ {link.expand.folder.icon}
+ {link.expand.folder.display_name}
+
+ {/if}
+ {#if link.expand?.['link_tags(link_id)'] && link.expand['link_tags(link_id)'].length > 0}
+ {#each link.expand['link_tags(link_id)'] as linkTag}
+ {#if linkTag.expand?.tag_id}
+
+ {/if}
+ {/each}
+ {/if}
+ {#if !link.is_active}
+
+
+ Inactive
+
+ {/if}
+ {#if isExpired}
+
+
+
+
+ Expired
+
+ {/if}
+ {#if link.password}
+
+
+
+
+ Protected
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {link.clicks || 0}
+
+
clicks
+
+
+ {#if link.max_clicks}
+
+
+
+
+
+ {link.max_clicks}
+
+
max
+
+ {/if}
+
+ {#if link.expires_at}
+
+
+
+
+
+ {new Date(link.expires_at).toLocaleDateString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: '2-digit',
+ })}
+
+
+ {/if}
+
+
+
+
+
+
+ {new Date(link.created).toLocaleDateString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ })}
+
+
+
+
+
+ {#if showQRCode}
+
+
+
+
+
+
+
+
+
Color
+
+ (qrColor = 'black')}
+ class="h-8 w-8 rounded border-2 bg-black {qrColor === 'black'
+ ? 'border-blue-500'
+ : 'border-theme-border'}"
+ title="Black"
+ >
+ (qrColor = 'white')}
+ class="h-8 w-8 rounded border-2 bg-white {qrColor === 'white'
+ ? 'border-blue-500'
+ : 'border-theme-border'}"
+ title="White"
+ >
+ (qrColor = 'gold')}
+ class="h-8 w-8 rounded border-2 {qrColor === 'gold'
+ ? 'border-blue-500'
+ : 'border-theme-border'}"
+ style="background: #f8d62b"
+ title="Gold"
+ >
+
+
+
+
+ Format
+
+ PNG
+ SVG
+ JPG
+
+
+
+
+
Rotation
+
+ {#each [0, 45, 90, 135, 180, 225, 270, 315] as angle}
+ rotateQR(angle)}
+ class="h-8 w-8 rounded border-2 text-xs font-bold {qrRotation === angle
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'border-theme-border'}"
+ title="{angle}°"
+ >
+ {angle}°
+
+ {/each}
+
+
+
+
+
+
+ Download{qrRotation !== 0 ? ` (${qrRotation}°)` : ''} as {qrFormat.toUpperCase()}
+
+ copyToClipboard(formatUrl(link.short_code), 'qr-copy', link.short_code)}
+ variant="secondary"
+ size="lg"
+ >
+ Copy URL
+
+
+
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte
new file mode 100644
index 000000000..09fa9a323
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkCardCompact.svelte
@@ -0,0 +1,234 @@
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte
new file mode 100644
index 000000000..f83490f92
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkCreationCard.svelte
@@ -0,0 +1,340 @@
+
+
+
+
+
+
+
+
+ +
+
+
+ {showBulkCreate
+ ? 'Mehrere Links erstellen'
+ : editingLink
+ ? 'Link bearbeiten'
+ : 'Neuen Link erstellen'}
+
+
+
+
+
+
(showBulkCreate = !showBulkCreate)}
+ class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all {showBulkCreate
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
+ : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}"
+ >
+ {showBulkCreate ? '← Einzelner Link' : 'Mehrere Links →'}
+
+
+
+
(isOpen = false)}
+ class="rounded-lg p-1 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ title="Formular ausblenden"
+ >
+
+
+
+
+
+
+
+
+ {#if showBulkCreate}
+
+
+ {:else}
+
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte b/apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte
new file mode 100644
index 000000000..37c3a3de2
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkCreationForm.svelte
@@ -0,0 +1,1002 @@
+
+
+ console.log('📤 Form onsubmit event fired!')}
+>
+ {#if editingLink}
+
+ {/if}
+
+
+ {#if generatedCode}
+
+ {/if}
+
+
+
+
+
+ URL kürzen:
+
+
+
+
handleKeydown(e, 1)}
+ class="w-full rounded-lg border-2 pl-10 pr-10 {isValidUrl
+ ? 'border-green-500 bg-green-50/50 focus:border-green-500 focus:ring-green-500 dark:bg-green-900/20'
+ : error && formData.url
+ ? 'border-red-500 bg-red-50/50 focus:border-red-500 focus:ring-red-500 dark:bg-red-900/20'
+ : 'border-theme-border bg-theme-surface focus:border-theme-accent focus:ring-2 focus:ring-theme-accent'} px-4 py-2 text-theme-text placeholder-theme-text-muted shadow-sm transition-all hover:shadow-md focus:outline-none"
+ />
+ {#if isValidUrl}
+
+ {/if}
+
+
+ {#if urlPreview && !isValidUrl}
+
+
+
+
+ Bitte geben Sie eine gültige URL ein (z.B. https://beispiel.de)
+
+ {/if}
+
+
+
+ {#if showShortlinkPreview && isValidUrl}
+
+
+
+
+
+
+
+ ✨ Ihre kurze URL wird sein:
+
+
+
+ {linkPreview || `${window.location.origin}/[code]`}
+
+ copyToClipboard(linkPreview)}
+ class="flex-none rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-200 {copiedToClipboard
+ ? 'scale-105 bg-green-500 text-white'
+ : 'bg-blue-600 text-white hover:scale-105 hover:bg-blue-700'}"
+ >
+ {copiedToClipboard ? '✓' : '📋'}
+
+
+
+
+
+
+
+ {#if workspace?.slug}
+
+
+
+
+
+ Workspace-Link: /w/{workspace.slug}/
+
+
+
+
+ {#if mode === 'advanced'}
+
{
+ customCode = e.currentTarget.value;
+ formData.customCode = e.currentTarget.value;
+ }}
+ placeholder="Eigener Code (optional)"
+ pattern="[a-zA-Z0-9_\-]+"
+ title="Nur Buchstaben, Zahlen, Bindestriche und Unterstriche erlaubt"
+ class="hover:border-theme-border-hover flex-1 rounded-lg border-2 border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text placeholder-theme-text-muted transition-all focus:outline-none focus:ring-2 focus:ring-theme-accent"
+ />
+ {/if}
+ {/if}
+
+
+ {#if user && !workspace?.slug}
+
+ {/if}
+
+ {/if}
+
+
+ {#if currentStep >= 3}
+
+
+ 2.
+ Geben Sie Ihrem Link einen Titel
+
+ (formData.title = e.currentTarget.value)}
+ onkeydown={(e) => handleKeydown(e, 3)}
+ class="w-full rounded-lg border-2 border-theme-border bg-theme-surface px-4 py-3 text-theme-text placeholder-theme-text-muted transition-all focus:outline-none focus:ring-2 focus:ring-theme-accent"
+ />
+
+ {/if}
+
+
+ {#if currentStep >= 4 && user}
+
+
+ 3.
+ Organisation (optional)
+
+
+ {#if mode === 'advanced' && folders.length > 0}
+
+
+ Ordner
+
+
+ Kein Ordner
+ {#each folders as folder}
+
+ {folder.icon}
+ {folder.display_name}
+
+ {/each}
+
+
+ {/if}
+
+
+ Tags
+
+
+ {#each selectedTags as tag}
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {#if currentStep >= 3 && mode === 'advanced'}
+
+
(showAdvancedOptions = !showAdvancedOptions)}
+ class="flex items-center gap-2 text-sm font-medium text-theme-text transition-colors hover:text-theme-accent"
+ >
+
+
+
+ Erweiterte Optionen
+
+
+ {/if}
+
+ {#if showAdvancedOptions && currentStep >= 3}
+
+
+
+ Beschreibung (optional)
+
+
+
+
+
+
+ {#if mode === 'advanced'}
+
+
+
+ Aktivieren um (optional)
+
+
+
+ Link ist inaktiv bis zu diesem Zeitpunkt
+
+
+
+
+ Läuft ab um (optional)
+
+
+
+ Überschreibt "Läuft ab in Tagen" wenn gesetzt
+
+
+
+ {/if}
+
+ {/if}
+
+ {#if mode === 'advanced'}
+
+
+
(showSocialMediaOptions = !showSocialMediaOptions)}
+ class="flex items-center gap-2 text-sm text-theme-accent hover:text-theme-accent-hover"
+ >
+
+
+
+ Social-Media-Vorschau (optional)
+
+
+
+ {#if showSocialMediaOptions}
+
+
+ Passen Sie an, wie Ihr Link erscheint, wenn er auf Social-Media-Plattformen geteilt wird
+
+
+
+
+
+
+ Vorschau-Beschreibung
+
+
+
+
+ {/if}
+ {/if}
+
+
+ {#if isValidUrl}
+
+
console.log('💯 Submit button clicked! Submitting form...')}
+ class="flex flex-1 transform items-center justify-center rounded-lg px-6 py-4 font-semibold text-white shadow-lg transition-all duration-300 {showSuccess
+ ? 'scale-105 bg-green-500'
+ : isSubmitting
+ ? 'bg-gradient-to-r from-blue-500 to-indigo-500'
+ : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:-translate-y-0.5 hover:from-blue-700 hover:to-indigo-700 hover:shadow-xl'} disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {#if showSuccess}
+
+
+
+ Erfolgreich erstellt!
+ {:else if isSubmitting}
+
+
+
+
+ {editingLink ? 'Wird aktualisiert...' : 'Wird erstellt...'}
+ {:else}
+ {editingLink ? '✏️ Link aktualisieren' : '🚀 Link erstellen'}
+ {/if}
+
+ {#if onCancel}
+
+ Abbrechen
+
+ {/if}
+
+ {/if}
+
+
+
+{#if error}
+
+{/if}
+
+{#if createdLink && !error}
+
+
+
+
+
+
+
Link erfolgreich erstellt!
+
+
+ {createdLink.url}
+
+ copyToClipboard(createdLink.url)}
+ class="rounded px-2 py-1 text-xs font-medium transition-colors {copiedToClipboard
+ ? 'bg-green-600 text-white'
+ : 'bg-green-700 text-white hover:bg-green-800'}"
+ >
+ {copiedToClipboard ? '✓ Kopiert' : 'Kopieren'}
+
+
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkList.svelte b/apps/uload/apps/web/src/lib/components/links/LinkList.svelte
new file mode 100644
index 000000000..4c28a8775
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkList.svelte
@@ -0,0 +1,200 @@
+
+
+{#if links && links.items && links.items.length > 0}
+ {#if viewMode === 'cards'}
+
+
+ {#each links.items as link}
+
+ {#if isSelectMode}
+
+ onToggleSelect(link.id)}
+ class="h-5 w-5 cursor-pointer rounded border-theme-border text-theme-primary focus:ring-theme-primary"
+ />
+
+ {/if}
+
+
+ {/each}
+
+
+ {:else}
+
+
+
+ {#if isSelectMode}
{/if}
+
Title
+
Short URL
+
Destination
+
Clicks
+
Created
+
Actions
+
+
+
+ {#if isSelectMode}
{/if}
+
Title
+
Short URL
+
Clicks
+
Actions
+
+
+
+ {#each links.items as link}
+ onToggleSelect(link.id)}
+ />
+ {/each}
+
+
+ {/if}
+
+ {#if links.totalPages > 1}
+
+ {#if links.page > 1}
+ onPageChange(links.page - 1)}
+ class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
+ >
+ Previous
+
+ {/if}
+
+ {#each Array(Math.min(5, links.totalPages)) as _, i}
+ {@const pageNum = Math.max(1, links.page - 2) + i}
+ {#if pageNum <= links.totalPages}
+ onPageChange(pageNum)}
+ class="rounded-lg px-3 py-2 transition-colors {pageNum === links.page
+ ? 'bg-theme-primary text-white'
+ : 'border border-theme-border bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
+ >
+ {pageNum}
+
+ {/if}
+ {/each}
+
+ {#if links.page < links.totalPages}
+ onPageChange(links.page + 1)}
+ class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-theme-text transition-colors hover:bg-theme-surface-hover"
+ >
+ Next
+
+ {/if}
+
+ {/if}
+{:else}
+
+
+ Keine Links gefunden. Versuchen Sie Ihre Filter anzupassen oder erstellen Sie Ihren ersten
+ Link!
+
+
+ window.dispatchEvent(new CustomEvent('show-create-form'))}
+ class="mt-4 inline-block rounded-lg bg-theme-primary px-6 py-2 font-medium text-white transition-colors hover:bg-theme-primary-hover"
+ >
+ Ersten Link erstellen
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte b/apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte
new file mode 100644
index 000000000..5814b3aca
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkListItem.svelte
@@ -0,0 +1,541 @@
+
+
+
+
+
+ {#if isSelectMode}
+
+
+
+ {/if}
+
+
+
+ {link.title || link.short_code}
+
+
+
+
+
+
+
+
+
+
+ {link.original_url}
+
+ {#if !link.is_active}
+ Inactive
+ {/if}
+ {#if link.password}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {link.clicks || 0}
+
+
+
+
+
+
+
{new Date(link.created).toLocaleDateString('de-DE')}
+ {#if link.last_clicked_at}
+
+ Last: {new Date(link.last_clicked_at).toLocaleDateString('de-DE')}
+
+ {/if}
+
+
+
+
+
copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
+ class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded-lg px-3 py-1.5 text-sm font-medium text-theme-primary transition"
+ title="Copy URL"
+ >
+ {#if copiedStates[link.id]}
+ ✓ Copied
+ {:else}
+ Copy URL
+ {/if}
+
+
+
',
+ color: '#2563eb',
+ },
+ {
+ label: 'QR Code',
+ icon: ' ',
+ color: '#16a34a',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
+ },
+ },
+ {
+ label: 'Edit',
+ icon: ' ',
+ color: '#9333ea',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
+ },
+ },
+ {
+ label: link.is_active ? 'Deactivate' : 'Activate',
+ type: 'form',
+ formAction: '?/toggle',
+ formData: { id: link.id, is_active: String(link.is_active) },
+ icon: link.is_active
+ ? ' '
+ : ' ',
+ color: link.is_active ? '#ea580c' : '#16a34a',
+ },
+ {
+ divider: true,
+ },
+ {
+ label: 'Delete',
+ icon: ' ',
+ color: '#dc2626',
+ type: 'form',
+ formAction: '?/delete',
+ formData: { id: link.id },
+ enhanceOptions: () => {
+ return async ({ update, result, cancel }) => {
+ if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
+ cancel();
+ return;
+ }
+ await update();
+ if (result.type === 'success') {
+ trackEvent(EVENTS.LINK_DELETED, {
+ short_code: link.short_code,
+ });
+ toastMessages.linkDeleted();
+ }
+ };
+ },
+ },
+ ]}
+ buttonText="Actions"
+ size="sm"
+ />
+
+
+
+
+
+
+ {#if isSelectMode}
+
+
+
+ {/if}
+
+
+
+ {link.title || link.short_code}
+
+ {#if link.expand?.['link_tags(link_id)']?.length > 0}
+
+ {#each link.expand['link_tags(link_id)'].slice(0, 2) as linkTag}
+ {#if linkTag.expand?.tag_id}
+
+ {/if}
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {link.clicks || 0}
+
+
+
+
+
+
+
copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
+ class="bg-theme-primary/10 hover:bg-theme-primary/20 rounded-lg px-3 py-1.5 text-sm font-medium text-theme-primary transition"
+ title="Copy URL"
+ >
+ {#if copiedStates[link.id]}
+ ✓
+ {:else}
+ Copy
+ {/if}
+
+
+
',
+ color: '#2563eb',
+ },
+ {
+ label: 'QR Code',
+ icon: ' ',
+ color: '#16a34a',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
+ },
+ },
+ {
+ label: 'Edit',
+ icon: ' ',
+ color: '#9333ea',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
+ },
+ },
+ {
+ label: link.is_active ? 'Deactivate' : 'Activate',
+ type: 'form',
+ formAction: '?/toggle',
+ formData: { id: link.id, is_active: String(link.is_active) },
+ icon: link.is_active
+ ? ' '
+ : ' ',
+ color: link.is_active ? '#ea580c' : '#16a34a',
+ },
+ {
+ divider: true,
+ },
+ {
+ label: 'Delete',
+ icon: ' ',
+ color: '#dc2626',
+ type: 'form',
+ formAction: '?/delete',
+ formData: { id: link.id },
+ enhanceOptions: () => {
+ return async ({ update, result, cancel }) => {
+ if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
+ cancel();
+ return;
+ }
+ await update();
+ if (result.type === 'success') {
+ trackEvent(EVENTS.LINK_DELETED, {
+ short_code: link.short_code,
+ });
+ toastMessages.linkDeleted();
+ }
+ };
+ },
+ },
+ ]}
+ buttonText="•••"
+ size="sm"
+ />
+
+
+
+
+
+
+
+ {#if isSelectMode}
+
+
+
+ Select
+
+
+ {/if}
+
+
+
+
+
+
+
+ {#if link.expand?.['link_tags(link_id)']?.length > 0}
+
+ {#each link.expand['link_tags(link_id)'] as linkTag}
+ {#if linkTag.expand?.tag_id}
+
+ {/if}
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+ {link.clicks || 0} clicks
+
+
+
+ {new Date(link.created).toLocaleDateString('de-DE')}
+
+
+
+ {#if !link.is_active}
+ Inactive
+ {/if}
+ {#if link.password}
+
+ {/if}
+
+
+
+
+
+
copyToClipboard(formatUrl(link.short_code), link.id, link.short_code)}
+ class="bg-theme-primary/10 hover:bg-theme-primary/20 flex-1 rounded-lg px-3 py-2 text-sm font-medium text-theme-primary transition"
+ >
+ {#if copiedStates[link.id]}
+ ✓ Copied
+ {:else}
+ Copy URL
+ {/if}
+
+
+ Analytics
+
+
',
+ color: '#16a34a',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('show-qr-modal', { detail: link }));
+ },
+ },
+ {
+ label: 'Edit',
+ icon: ' ',
+ color: '#9333ea',
+ action: () => {
+ window.dispatchEvent(new CustomEvent('edit-link', { detail: link }));
+ },
+ },
+ {
+ label: link.is_active ? 'Deactivate' : 'Activate',
+ type: 'form',
+ formAction: '?/toggle',
+ formData: { id: link.id, is_active: String(link.is_active) },
+ icon: link.is_active
+ ? ' '
+ : ' ',
+ color: link.is_active ? '#ea580c' : '#16a34a',
+ },
+ {
+ divider: true,
+ },
+ {
+ label: 'Delete',
+ icon: ' ',
+ color: '#dc2626',
+ type: 'form',
+ formAction: '?/delete',
+ formData: { id: link.id },
+ enhanceOptions: () => {
+ return async ({ update, result, cancel }) => {
+ if (!confirm('Möchtest du diesen Link wirklich löschen?')) {
+ cancel();
+ return;
+ }
+ await update();
+ if (result.type === 'success') {
+ trackEvent(EVENTS.LINK_DELETED, {
+ short_code: link.short_code,
+ });
+ toastMessages.linkDeleted();
+ }
+ };
+ },
+ },
+ ]}
+ buttonText="•••"
+ size="sm"
+ />
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/links/LinkStats.svelte b/apps/uload/apps/web/src/lib/components/links/LinkStats.svelte
new file mode 100644
index 000000000..f5f22b157
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/links/LinkStats.svelte
@@ -0,0 +1,314 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Math.abs(parseFloat(stats().clickTrend))}%
+
+
+
+
{formatNumber(stats().totalClicks)}
+
Total Clicks
+
+
+
+
+
+
+
+
{stats().activeLinks}
+
Active Links
+
+
+
+
+
+
+
+
{stats().avgCtr}%
+
Avg. Engagement
+
+
+
+
+
+
+
+
+ {stats().activeLinks > 0 ? Math.floor(stats().totalClicks / stats().activeLinks) : 0}
+
+
Clicks per Link
+
+
+
+
+
+
+
+
+
+
+ Click Activity (24h)
+
+
+ {#each stats().hourlyDistribution as hour}
+
+ {/each}
+
+
+ 00:00
+ 06:00
+ 12:00
+ 18:00
+ 23:00
+
+
+
+
+
+
+
+ Device Types
+
+
+
+
+ Desktop
+ {stats().deviceBreakdown.desktop}
+
+
+
+
+
+ Mobile
+ {stats().deviceBreakdown.mobile}
+
+
+
+
+
+ Tablet
+ {stats().deviceBreakdown.tablet}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Top Performing Links
+
+
+ {#each stats().topLinks as link, i}
+
+
+
#{i + 1}
+
+
+ {link.title || link.short_url}
+
+
+ {link.short_url}
+
+
+
+
+
{link.clicks || 0}
+
clicks
+
+
+
+
+
+ {:else}
+
No links with clicks yet
+ {/each}
+
+
+
+
+
+
+
+ Recently Created
+
+
+ {#each stats().recentLinks as link}
+
+
+
+ {link.title || link.short_url}
+
+
+ {new Date(link.created).toLocaleDateString()}
+
+
+
+
{link.clicks || 0}
+
clicks
+
+
+
+
+
+ {:else}
+
No links created yet
+ {/each}
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte b/apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte
new file mode 100644
index 000000000..8184e1c83
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/mobile/InstallPWABanner.svelte
@@ -0,0 +1,233 @@
+
+
+{#if showBanner}
+
+
+
+
+
+
+
+
+
+
+
+
Install uLoad
+
+ Add to home screen for quick access
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Native app feel
+
+
+
+
+
+
+ {#if isInstalling}
+
+
+
+
+
+ Installing...
+
+ {:else}
+ Install
+ {/if}
+
+
+
+ Not now
+
+
+
+
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte b/apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte
new file mode 100644
index 000000000..56374b14c
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/security/TOTPSetup.svelte
@@ -0,0 +1,411 @@
+
+
+
+
+
+
+
Zwei-Faktor-Authentifizierung einrichten
+
Erhöhen Sie die Sicherheit Ihres Kontos mit 2FA
+
+
+
+
+
+ {#each [1, 2, 3] as stepNumber}
+
+
+ {stepNumber}
+
+ {#if stepNumber < 3}
+
+ {/if}
+
+ {/each}
+
+
+
+ {#if step === 1}
+
+
+
1. Authenticator-App einrichten
+
+
+
+
+ {#if qrCodeURL}
+
+
+
+ QR Code
+
+
+ Scannen Sie diesen Code mit Ihrer Authenticator-App
+
+
+ {:else}
+
+ {/if}
+
+
+
+ Scannen Sie den QR-Code mit einer Authenticator-App wie Google Authenticator, Authy oder
+ 1Password
+
+
+
+
+
+ Manueller Setup-Code
+
+
+
Falls Sie den QR-Code nicht scannen können:
+
+ {secret}
+
+
navigator.clipboard.writeText(secret)}
+ class="mt-2 text-xs text-blue-600 hover:text-blue-700"
+ >
+ Code kopieren
+
+
+
+
+
+
+
+ Abbrechen
+
+ (step = 2)}
+ disabled={!qrCodeURL}
+ class="flex-1 rounded-lg bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ Weiter
+
+
+
+ {:else if step === 2}
+
+
+
2. Code verifizieren
+
+
+
+ Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:
+
+
+
+
+
+
+
+
+ {#if import.meta.env.DEV}
+
+
Aktueller Code: {currentToken}
+
Läuft ab in: {timeRemaining}s
+
+ {/if}
+
+
Der Code ändert sich alle 30 Sekunden
+
+
+
+
(step = 1)}
+ class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-gray-700 transition-colors hover:bg-gray-50"
+ >
+ Zurück
+
+
+ {#if isVerifying}
+
+
+
+
+
+ Verifiziere...
+
+ {:else}
+ Verifizieren
+ {/if}
+
+
+
+ {:else if step === 3}
+
+
+
3. Backup-Codes sichern
+
+
+
+
+
+
+
+
+
Wichtig: Backup-Codes sichern
+
+ Bewahren Sie diese Codes an einem sicheren Ort auf. Sie können verwendet werden,
+ wenn Sie keinen Zugang zu Ihrer Authenticator-App haben.
+
+
+
+
+
+
+
+ {#each backupCodes as code}
+
+ {code}
+
+ {/each}
+
+
+
+
+
+
+
+
+ Kopieren
+
+
+
+
+
+ Download
+
+
+
+
+
+
+ 2FA-Einrichtung abschließen
+
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/tags/TagStats.svelte b/apps/uload/apps/web/src/lib/components/tags/TagStats.svelte
new file mode 100644
index 000000000..3ad69e97e
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/tags/TagStats.svelte
@@ -0,0 +1,409 @@
+
+
+
+
+
+
+
+
+
+
{stats().totalTags}
+
Total Tags
+
+
+
+
+
+
+
+
{stats().usedTags}
+
Active Tags
+
+
+
+
+
+
+
+
{formatNumber(stats().totalClicks)}
+
Tag Clicks
+
+
+
+
+
+
+
+
{stats().avgLinksPerTag}
+
Links per Tag
+
+
+
+
+
+
+
+
+
+
+ Usage Distribution
+
+
+
+
+
+
+ High Usage (10+ links)
+
+ {stats().distribution.highUsage}
+
+
+
+
+
+
+
+ Medium Usage (5-10 links)
+
+
{stats().distribution.mediumUsage}
+
+
+
+
+
+
+
+ Low Usage (1-4 links)
+
+ {stats().distribution.lowUsage}
+
+
+
+
+
+
+
+ Unused
+
+ {stats().distribution.unused}
+
+
+
+
+
+
+
+
+
+
+ Color Distribution
+
+
+ {#each Object.entries(stats().colorDistribution).slice(0, 8) as [color, count]}
+
+
+ {count}
+
+
{color}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+ Top by Clicks
+
+
+ {#each stats().topByClicks as tag, i}
+
+
+ #{i + 1}
+
+ {tag.name}
+
+
+
+ {formatNumber(tag.totalClicks || 0)}
+ clicks
+
+
+ {:else}
+
No tags with clicks yet
+ {/each}
+
+
+
+
+
+
+
+ Most Used
+
+
+ {#each stats().mostUsedTags as tag}
+ {@const usage = getUsageLevel(tag.linkCount || 0)}
+
+
+
+ {tag.name}
+
+
+
+
+ {tag.linkCount || 0}
+ links
+
+
+ {:else}
+
No tags used yet
+ {/each}
+
+
+
+
+
+
+
+ Recently Created
+
+
+ {#each stats().recentTags as tag}
+
+
+
+ {tag.name}
+
+
+
+ {new Date(tag.created).toLocaleDateString()}
+
+
+ {:else}
+
No tags created yet
+ {/each}
+
+
+
+
+
+
+
+
+ Tag Performance Insights
+
+
+
+
+ {((stats().usedTags / stats().totalTags) * 100).toFixed(0)}%
+
+
Tag Utilization Rate
+
+
+
{stats().avgEngagement}%
+
Average Engagement
+
+
+
+ {stats().usedTags > 0 ? Math.floor(stats().totalClicks / stats().usedTags) : 0}
+
+
Clicks per Active Tag
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte b/apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte
new file mode 100644
index 000000000..3e9f81640
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/templates/CreateTemplateModal.svelte
@@ -0,0 +1,322 @@
+
+
+{#if show && card}
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
Create Template
+
Share your card design with the community
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Template Name *
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+ Category *
+
+
+ {#each categories as category}
+ {category.label}
+ {/each}
+
+
+
+
+
+
+ Tags
+
+
+
+ Separate tags with commas. Help others find your template!
+
+
+
+
+
+
+
+
+
+
+ Allow others to duplicate this template
+
+
+
+
+ {#if error}
+
+ {/if}
+
+
+
+
+
Preview
+
+
+
+
+
+
+
+ Mode:
+ {card.config?.mode || 'Unknown'}
+
+ {#if isBeginnerCard(card.config)}
+
+ Modules:
+ {card.config.modules.length}
+
+ {/if}
+ {#if card.variant}
+
+ Variant:
+ {card.variant}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {#if loading}
+
+ {/if}
+ {loading ? 'Creating...' : 'Create Template'}
+
+
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte b/apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte
new file mode 100644
index 000000000..cebb3fa47
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/templates/TemplateCard.svelte
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if template.is_featured}
+
+ Featured
+
+ {/if}
+
+
+
+ {#if template.category}
+
+ {template.category}
+
+ {/if}
+
+
+
+
+ onPreview(template)}
+ class="rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100"
+ >
+ Quick Preview
+
+
+
+
+
+
+
+
+ {template.metadata?.name || 'Unnamed Template'}
+
+ {#if template.metadata?.description && !compact}
+
+ {template.metadata.description}
+
+ {/if}
+
+
+
+ {#if template.tags && template.tags.length > 0 && !compact}
+
+ {#each template.tags.slice(0, 3) as tag}
+
+ {tag}
+
+ {/each}
+ {#if template.tags.length > 3}
+
+ +{template.tags.length - 3}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {template.usage_count || 0}
+
+
+
+
+
+
+
+ {template.likes_count || 0}
+
+
+
+
+ {#if template.created && !compact}
+
{new Date(template.created).toLocaleDateString()}
+ {/if}
+
+
+
+
+ {#if !compact}
+ onPreview(template)}
+ class="flex-1 rounded-lg bg-theme-surface-hover px-3 py-2 text-sm font-medium text-theme-text hover:bg-theme-border"
+ >
+ Preview
+
+ {/if}
+ onUse(template)}
+ class="{compact
+ ? 'flex-1'
+ : 'flex-1'} hover:bg-theme-primary/90 rounded-lg bg-theme-primary px-3 py-2 text-sm font-medium text-white"
+ >
+ Use Template
+
+
+
+
+ {#if !compact}
+
+
+
onLike(template)}
+ class="flex items-center gap-1 rounded p-1.5 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-red-500"
+ title="Like this template"
+ >
+
+
+
+
+
+
+
+
onDuplicate(template)}
+ title="Add to my collection"
+ class="rounded p-1.5 transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+
+
onShare(template)}
+ title="Share template"
+ class="rounded p-1.5 transition-colors hover:bg-theme-surface-hover"
+ >
+
+
+
+
+
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte b/apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte
new file mode 100644
index 000000000..164dbc3f1
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/components/templates/TemplatePreviewModal.svelte
@@ -0,0 +1,267 @@
+
+
+{#if show && template}
+
+
+
e.stopPropagation()}
+ >
+
+
+
+
+ {template.metadata?.name || 'Template Preview'}
+
+ {#if template.metadata?.description}
+
{template.metadata.description}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if template.category}
+
+ {template.category}
+
+ {/if}
+ {#if template.is_featured}
+
+ Featured
+
+ {/if}
+
+
+
+
+
+
+ {template.usage_count || 0} uses
+
+
+
+
+
+ {template.likes_count || 0} likes
+
+
+
+
+
+
+
+
+
+
+
+
Configuration
+
+
+ Mode:
+ {template.config?.mode || 'Unknown'}
+
+ {#if isBeginnerCard(template.config)}
+
+ Modules:
+ {template.config.modules.length}
+
+ {/if}
+ {#if template.variant}
+
+ Variant:
+ {template.variant}
+
+ {/if}
+ {#if template.created}
+
+ Created:
+ {new Date(template.created).toLocaleDateString()}
+
+ {/if}
+
+
+
+
+
+
Statistics
+
+
+
{template.usage_count || 0}
+
Times Used
+
+
+
{template.likes_count || 0}
+
Likes
+
+
+
+
+
+
+ {#if isBeginnerCard(template.config) && template.config.modules.length > 0}
+
+
Included Modules
+
+ {#each template.config.modules as module, index}
+
+
+ {index + 1}
+
+
+
{module.type} Module
+ {#if module.props?.title}
+
{module.props.title}
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if template.tags && template.tags.length > 0}
+
+
Tags
+
+ {#each template.tags as tag}
+
+ {tag}
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
onLike(template)}
+ class="flex items-center gap-2 rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text transition-colors hover:bg-theme-border"
+ >
+
+
+
+ Like
+
+
{
+ onDuplicate(template);
+ onClose();
+ }}
+ class="flex-1 rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text transition-colors hover:bg-theme-border"
+ >
+ Add to Collection
+
+
{
+ onUse(template);
+ onClose();
+ }}
+ class="hover:bg-theme-primary/90 flex-1 rounded-lg bg-theme-primary px-4 py-2 font-medium text-white transition-colors"
+ >
+ Use This Template
+
+
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/lib/content/index.ts b/apps/uload/apps/web/src/lib/content/index.ts
new file mode 100644
index 000000000..e8c8a233f
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/content/index.ts
@@ -0,0 +1,186 @@
+import {
+ blogSchema,
+ authorSchema,
+ type BlogPost,
+ type Author,
+ type BlogPostWithMeta,
+} from '../../content/config';
+import { error } from '@sveltejs/kit';
+import { dev } from '$app/environment';
+
+// Cache für Performance
+const contentCache = new Map();
+const CACHE_DURATION = dev ? 0 : 1000 * 60 * 5; // 5 Min in Production
+
+export async function getCollection(collection: 'blog' | 'authors'): Promise {
+ const cacheKey = `collection-${collection}`;
+ const cached = contentCache.get(cacheKey);
+
+ if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
+ return cached.data;
+ }
+
+ let items: T[] = [];
+
+ if (collection === 'blog') {
+ items = (await getBlogPosts()) as T[];
+ } else if (collection === 'authors') {
+ items = (await getAuthors()) as T[];
+ }
+
+ contentCache.set(cacheKey, {
+ data: items,
+ timestamp: Date.now(),
+ });
+
+ return items;
+}
+
+async function getBlogPosts(): Promise {
+ const postModules = import.meta.glob('/src/content/blog/**/*.md');
+ const posts: BlogPostWithMeta[] = [];
+
+ for (const [path, resolver] of Object.entries(postModules)) {
+ // Skip drafts in production
+ if (!dev && path.includes('_drafts')) continue;
+
+ try {
+ const module = (await resolver()) as any;
+ const { metadata } = module;
+
+ // Validiere mit Zod Schema
+ const validatedPost = blogSchema.parse(metadata);
+
+ // Skip drafts based on frontmatter
+ if (!dev && validatedPost.draft) continue;
+
+ // Füge zusätzliche Metadaten hinzu
+ const slug = path
+ .split('/')
+ .pop()
+ ?.replace('.md', '')
+ .replace(/^\d{4}-\d{2}-\d{2}-/, ''); // Datum aus Filename entfernen
+
+ if (!slug) continue;
+
+ posts.push({
+ ...validatedPost,
+ slug,
+ readingTime: calculateReadingTime(module.default?.default || module.default || ''),
+ path,
+ });
+ } catch (err) {
+ console.error(`Error loading ${path}:`, err);
+ if (dev) throw err; // In Dev Fehler werfen
+ }
+ }
+
+ // Sortiere nach Datum (neueste zuerst)
+ return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+}
+
+async function getAuthors(): Promise {
+ const authorModules = import.meta.glob('/src/content/authors/*.json', {
+ import: 'default',
+ });
+
+ const authors: Author[] = [];
+
+ for (const [path, resolver] of Object.entries(authorModules)) {
+ try {
+ const data = (await resolver()) as any;
+ const validated = authorSchema.parse(data);
+ authors.push(validated);
+ } catch (err) {
+ console.error(`Error loading author ${path}:`, err);
+ }
+ }
+
+ return authors;
+}
+
+export async function getEntry(collection: 'blog' | 'authors', slug: string): Promise {
+ const items = await getCollection(collection);
+
+ if (collection === 'blog') {
+ return (items as any[]).find((item) => item.slug === slug) || null;
+ }
+
+ return (items as any[]).find((item) => item.id === slug) || null;
+}
+
+// Helper Functions
+function calculateReadingTime(content: string): number {
+ const wordsPerMinute = 200;
+ const text = content.replace(/<[^>]*>/g, ''); // Strip HTML
+ const words = text.split(/\s+/).length;
+ return Math.ceil(words / wordsPerMinute);
+}
+
+// Blog-spezifische Helpers
+export async function getBlogPostsByTag(tag: string): Promise {
+ const posts = await getCollection('blog');
+ return posts.filter((post) => post.tags.includes(tag));
+}
+
+export async function getBlogPostsByCategory(category: string): Promise {
+ const posts = await getCollection('blog');
+ return posts.filter((post) => post.category === category);
+}
+
+export async function getFeaturedPosts(): Promise {
+ const posts = await getCollection('blog');
+ return posts.filter((post) => post.featured);
+}
+
+export async function getRelatedPosts(currentSlug: string, limit = 3): Promise {
+ const posts = await getCollection('blog');
+ const current = posts.find((p) => p.slug === currentSlug);
+
+ if (!current) return [];
+
+ // Finde Posts mit ähnlichen Tags
+ const related = posts
+ .filter((p) => p.slug !== currentSlug)
+ .map((post) => ({
+ post,
+ score: post.tags.filter((tag) => current.tags.includes(tag)).length,
+ }))
+ .filter((item) => item.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .slice(0, limit)
+ .map((item) => item.post);
+
+ return related;
+}
+
+// Categories und Tags
+export async function getAllCategories() {
+ const posts = await getCollection('blog');
+ const categories = new Map();
+
+ posts.forEach((post) => {
+ categories.set(post.category, (categories.get(post.category) || 0) + 1);
+ });
+
+ return Array.from(categories.entries()).map(([name, count]) => ({
+ name,
+ slug: name.toLowerCase(),
+ count,
+ }));
+}
+
+export async function getAllTags() {
+ const posts = await getCollection('blog');
+ const tags = new Map();
+
+ posts.forEach((post) => {
+ post.tags.forEach((tag) => {
+ tags.set(tag, (tags.get(tag) || 0) + 1);
+ });
+ });
+
+ return Array.from(tags.entries())
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => b.count - a.count);
+}
diff --git a/apps/uload/apps/web/src/lib/data/guest-seed.ts b/apps/uload/apps/web/src/lib/data/guest-seed.ts
new file mode 100644
index 000000000..53f867daa
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/data/guest-seed.ts
@@ -0,0 +1,103 @@
+import type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './local-store';
+
+export const guestFolders: LocalFolder[] = [
+ {
+ id: 'folder-personal',
+ name: 'Persönlich',
+ color: '#3b82f6',
+ order: 0,
+ },
+ {
+ id: 'folder-work',
+ name: 'Arbeit',
+ color: '#10b981',
+ order: 1,
+ },
+];
+
+export const guestTags: LocalTag[] = [
+ {
+ id: 'tag-social',
+ name: 'Social Media',
+ slug: 'social-media',
+ color: '#8b5cf6',
+ icon: null,
+ isPublic: false,
+ usageCount: 2,
+ },
+ {
+ id: 'tag-docs',
+ name: 'Dokumentation',
+ slug: 'dokumentation',
+ color: '#f59e0b',
+ icon: null,
+ isPublic: false,
+ usageCount: 1,
+ },
+ {
+ id: 'tag-marketing',
+ name: 'Marketing',
+ slug: 'marketing',
+ color: '#ef4444',
+ icon: null,
+ isPublic: false,
+ usageCount: 1,
+ },
+];
+
+export const guestLinks: LocalLink[] = [
+ {
+ id: 'link-welcome',
+ shortCode: 'welcome',
+ originalUrl: 'https://ulo.ad',
+ title: 'Willkommen bei uLoad!',
+ description: 'Dein erster gekürzter Link. Klicke hier um zu sehen wie Analytics funktionieren.',
+ isActive: true,
+ clickCount: 42,
+ folderId: 'folder-personal',
+ order: 0,
+ },
+ {
+ id: 'link-github',
+ shortCode: 'gh-demo',
+ originalUrl: 'https://github.com',
+ title: 'GitHub',
+ description: 'Beispiel-Link mit Tags',
+ isActive: true,
+ clickCount: 15,
+ folderId: 'folder-work',
+ order: 0,
+ },
+ {
+ id: 'link-docs',
+ shortCode: 'docs',
+ originalUrl: 'https://docs.example.com/getting-started',
+ title: 'Dokumentation',
+ description: 'Link mit UTM-Tracking',
+ isActive: true,
+ clickCount: 8,
+ utmSource: 'newsletter',
+ utmMedium: 'email',
+ utmCampaign: 'onboarding',
+ folderId: 'folder-work',
+ order: 1,
+ },
+ {
+ id: 'link-expired',
+ shortCode: 'old-promo',
+ originalUrl: 'https://example.com/promo',
+ title: 'Abgelaufene Promotion',
+ description: 'Dieser Link ist deaktiviert — so sieht ein inaktiver Link aus.',
+ isActive: false,
+ clickCount: 234,
+ folderId: 'folder-personal',
+ order: 1,
+ },
+];
+
+export const guestLinkTags: LocalLinkTag[] = [
+ { id: 'lt-1', linkId: 'link-github', tagId: 'tag-social' },
+ { id: 'lt-2', linkId: 'link-docs', tagId: 'tag-docs' },
+ { id: 'lt-3', linkId: 'link-welcome', tagId: 'tag-social' },
+ { id: 'lt-4', linkId: 'link-expired', tagId: 'tag-marketing' },
+];
diff --git a/apps/uload/apps/web/src/lib/data/local-store.ts b/apps/uload/apps/web/src/lib/data/local-store.ts
new file mode 100644
index 000000000..03dea8f4e
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/data/local-store.ts
@@ -0,0 +1,97 @@
+import { createLocalStore, type BaseRecord } from '@manacore/local-store';
+
+const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
+
+// ============================================
+// Record Types
+// ============================================
+
+export interface LocalLink extends BaseRecord {
+ shortCode: string;
+ customCode?: string | null;
+ originalUrl: string;
+ title?: string | null;
+ description?: string | null;
+ isActive: boolean;
+ password?: string | null;
+ maxClicks?: number | null;
+ expiresAt?: string | null;
+ clickCount: number;
+ qrCodeUrl?: string | null;
+ utmSource?: string | null;
+ utmMedium?: string | null;
+ utmCampaign?: string | null;
+ folderId?: string | null;
+ order: number;
+}
+
+export interface LocalTag extends BaseRecord {
+ name: string;
+ slug: string;
+ color?: string | null;
+ icon?: string | null;
+ isPublic: boolean;
+ usageCount: number;
+}
+
+export interface LocalFolder extends BaseRecord {
+ name: string;
+ color?: string | null;
+ order: number;
+}
+
+export interface LocalLinkTag extends BaseRecord {
+ linkId: string;
+ tagId: string;
+}
+
+// ============================================
+// Guest Seed Data
+// ============================================
+
+import { guestLinks, guestTags, guestFolders, guestLinkTags } from './guest-seed';
+
+// ============================================
+// Store
+// ============================================
+
+export const uloadStore = createLocalStore({
+ appId: 'uload',
+ collections: [
+ {
+ name: 'links',
+ indexes: [
+ 'shortCode',
+ 'isActive',
+ 'folderId',
+ 'order',
+ 'clickCount',
+ '[folderId+order]',
+ '[isActive+order]',
+ ],
+ guestSeed: guestLinks,
+ },
+ {
+ name: 'tags',
+ indexes: ['slug', 'name'],
+ guestSeed: guestTags,
+ },
+ {
+ name: 'folders',
+ indexes: ['order'],
+ guestSeed: guestFolders,
+ },
+ {
+ name: 'linkTags',
+ indexes: ['linkId', 'tagId', '[linkId+tagId]'],
+ guestSeed: guestLinkTags,
+ },
+ ],
+ sync: { serverUrl: SYNC_SERVER_URL },
+});
+
+// Typed collection accessors
+export const linkCollection = uloadStore.collection('links');
+export const tagCollection = uloadStore.collection('tags');
+export const folderCollection = uloadStore.collection('folders');
+export const linkTagCollection = uloadStore.collection('linkTags');
diff --git a/apps/uload/apps/web/src/lib/db/index.ts b/apps/uload/apps/web/src/lib/db/index.ts
new file mode 100644
index 000000000..e34e9152c
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/db/index.ts
@@ -0,0 +1,24 @@
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import * as schema from './schema';
+
+// Get connection string from environment
+const connectionString =
+ process.env.DATABASE_URL || 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev';
+
+// Connection pool for queries
+export const client = postgres(connectionString, {
+ max: 10,
+ idle_timeout: 20,
+ connect_timeout: 10,
+});
+
+// Drizzle instance with schema
+export const db = drizzle(client, { schema });
+
+// Types for convenience
+export type DB = typeof db;
+export type TX = Parameters[0]>[0];
+
+// Export all schema tables and relations for easy access
+export * from './schema';
diff --git a/apps/uload/apps/web/src/lib/db/schema.ts b/apps/uload/apps/web/src/lib/db/schema.ts
new file mode 100644
index 000000000..1fe321259
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/db/schema.ts
@@ -0,0 +1,413 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ boolean,
+ integer,
+ timestamp,
+ jsonb,
+ index,
+} from 'drizzle-orm/pg-core';
+import { relations } from 'drizzle-orm';
+
+// ============================================
+// Users Table
+// ============================================
+export const users = pgTable(
+ 'users',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ externalAuthId: text('external_auth_id').unique(), // For external auth provider
+ email: text('email').unique().notNull(),
+ username: text('username').unique().notNull(),
+ name: text('name'),
+ avatarUrl: text('avatar_url'),
+ bio: text('bio'),
+ location: text('location'),
+ website: text('website'),
+ github: text('github'),
+ twitter: text('twitter'),
+ linkedin: text('linkedin'),
+ instagram: text('instagram'),
+ publicProfile: boolean('public_profile').default(false),
+ showClickStats: boolean('show_click_stats').default(true),
+ emailNotifications: boolean('email_notifications').default(true),
+ defaultExpiry: integer('default_expiry'),
+ profileBackground: text('profile_background'),
+ verified: boolean('verified').default(false),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ emailIdx: index('users_email_idx').on(table.email),
+ usernameIdx: index('users_username_idx').on(table.username),
+ externalAuthIdIdx: index('users_external_auth_id_idx').on(table.externalAuthId),
+ })
+);
+
+// ============================================
+// Accounts Table (Business/Team Accounts)
+// ============================================
+export const accounts = pgTable(
+ 'accounts',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ owner: uuid('owner')
+ .references(() => users.id)
+ .notNull(),
+ isActive: boolean('is_active').default(true),
+ planType: text('plan_type', { enum: ['free', 'team', 'enterprise'] }).default('free'),
+ settings: jsonb('settings'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ ownerIdx: index('accounts_owner_idx').on(table.owner),
+ })
+);
+
+// ============================================
+// Workspaces Table
+// ============================================
+export const workspaces = pgTable(
+ 'workspaces',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ slug: text('slug').unique().notNull(),
+ type: text('type', { enum: ['personal', 'team'] }).notNull(),
+ owner: uuid('owner')
+ .references(() => users.id)
+ .notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ slugIdx: index('workspaces_slug_idx').on(table.slug),
+ ownerIdx: index('workspaces_owner_idx').on(table.owner),
+ })
+);
+
+// ============================================
+// Links Table
+// ============================================
+export const links = pgTable(
+ 'links',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ shortCode: text('short_code').unique().notNull(),
+ customCode: text('custom_code'),
+ originalUrl: text('original_url').notNull(),
+ title: text('title'),
+ description: text('description'),
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
+ isActive: boolean('is_active').default(true),
+ password: text('password'), // hashed
+ maxClicks: integer('max_clicks'),
+ expiresAt: timestamp('expires_at'),
+ clickCount: integer('click_count').default(0),
+ qrCodeUrl: text('qr_code_url'), // File Storage URL
+ tags: jsonb('tags').$type(),
+ utmSource: text('utm_source'),
+ utmMedium: text('utm_medium'),
+ utmCampaign: text('utm_campaign'),
+ accountOwner: uuid('account_owner').references(() => accounts.id),
+ workspaceId: uuid('workspace_id').references(() => workspaces.id),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ userIdIdx: index('links_user_id_idx').on(table.userId),
+ shortCodeIdx: index('links_short_code_idx').on(table.shortCode),
+ workspaceIdIdx: index('links_workspace_id_idx').on(table.workspaceId),
+ accountOwnerIdx: index('links_account_owner_idx').on(table.accountOwner),
+ isActiveIdx: index('links_is_active_idx').on(table.isActive),
+ })
+);
+
+// ============================================
+// Clicks Table (Analytics)
+// ============================================
+export const clicks = pgTable(
+ 'clicks',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ linkId: uuid('link_id')
+ .references(() => links.id, { onDelete: 'cascade' })
+ .notNull(),
+ ipHash: text('ip_hash'),
+ userAgent: text('user_agent'),
+ referer: text('referer'),
+ browser: text('browser'),
+ deviceType: text('device_type'),
+ os: text('os'),
+ country: text('country'),
+ city: text('city'),
+ clickedAt: timestamp('clicked_at').defaultNow().notNull(),
+ utmSource: text('utm_source'),
+ utmMedium: text('utm_medium'),
+ utmCampaign: text('utm_campaign'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ linkIdIdx: index('clicks_link_id_idx').on(table.linkId),
+ clickedAtIdx: index('clicks_clicked_at_idx').on(table.clickedAt),
+ countryIdx: index('clicks_country_idx').on(table.country),
+ })
+);
+
+// ============================================
+// Tags Table
+// ============================================
+export const tags = pgTable(
+ 'tags',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ slug: text('slug').notNull(),
+ color: text('color'),
+ icon: text('icon'),
+ isPublic: boolean('is_public').default(false),
+ usageCount: integer('usage_count').default(0),
+ userId: uuid('user_id').references(() => users.id),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ userIdIdx: index('tags_user_id_idx').on(table.userId),
+ slugIdx: index('tags_slug_idx').on(table.slug),
+ })
+);
+
+// ============================================
+// Link-Tags Junction Table
+// ============================================
+export const linkTags = pgTable(
+ 'link_tags',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ linkId: uuid('link_id')
+ .references(() => links.id, { onDelete: 'cascade' })
+ .notNull(),
+ tagId: uuid('tag_id')
+ .references(() => tags.id, { onDelete: 'cascade' })
+ .notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ linkIdIdx: index('link_tags_link_id_idx').on(table.linkId),
+ tagIdIdx: index('link_tags_tag_id_idx').on(table.tagId),
+ uniqueLinkTag: index('link_tags_unique_idx').on(table.linkId, table.tagId),
+ })
+);
+
+// ============================================
+// Notifications Table
+// ============================================
+export const notifications = pgTable(
+ 'notifications',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id')
+ .references(() => users.id, { onDelete: 'cascade' })
+ .notNull(),
+ type: text('type').notNull(),
+ title: text('title').notNull(),
+ message: text('message').notNull(),
+ data: jsonb('data'),
+ read: boolean('read').default(false),
+ actionUrl: text('action_url'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ userIdIdx: index('notifications_user_id_idx').on(table.userId),
+ readIdx: index('notifications_read_idx').on(table.read),
+ })
+);
+
+// ============================================
+// Shared Access Table (Team Invitations)
+// ============================================
+export const sharedAccess = pgTable(
+ 'shared_access',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ owner: uuid('owner')
+ .references(() => users.id)
+ .notNull(),
+ userId: uuid('user_id').references(() => users.id),
+ permissions: jsonb('permissions'),
+ invitationStatus: text('invitation_status', {
+ enum: ['pending', 'accepted', 'declined'],
+ }).default('pending'),
+ acceptedAt: timestamp('accepted_at'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ ownerIdx: index('shared_access_owner_idx').on(table.owner),
+ userIdIdx: index('shared_access_user_id_idx').on(table.userId),
+ statusIdx: index('shared_access_status_idx').on(table.invitationStatus),
+ })
+);
+
+// ============================================
+// Pending Invitations Table
+// ============================================
+export const pendingInvitations = pgTable(
+ 'pending_invitations',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ email: text('email').notNull(),
+ token: text('token').unique().notNull(),
+ owner: uuid('owner')
+ .references(() => users.id)
+ .notNull(),
+ expiresAt: timestamp('expires_at').notNull(),
+ acceptedAt: timestamp('accepted_at'),
+ acceptedBy: uuid('accepted_by').references(() => users.id),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ emailIdx: index('pending_invitations_email_idx').on(table.email),
+ tokenIdx: index('pending_invitations_token_idx').on(table.token),
+ ownerIdx: index('pending_invitations_owner_idx').on(table.owner),
+ })
+);
+
+// ============================================
+// Feature Requests Table
+// ============================================
+export const featureRequests = pgTable(
+ 'feature_requests',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ title: text('title').notNull(),
+ description: text('description').notNull(),
+ userId: uuid('user_id')
+ .references(() => users.id)
+ .notNull(),
+ status: text('status', {
+ enum: ['pending', 'reviewing', 'planned', 'completed', 'rejected'],
+ }).default('pending'),
+ voteCount: integer('vote_count').default(0),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ userIdIdx: index('feature_requests_user_id_idx').on(table.userId),
+ statusIdx: index('feature_requests_status_idx').on(table.status),
+ voteCountIdx: index('feature_requests_vote_count_idx').on(table.voteCount),
+ })
+);
+
+// ============================================
+// Feature Votes Table
+// ============================================
+export const featureVotes = pgTable(
+ 'feature_votes',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ featureRequestId: uuid('feature_request_id')
+ .references(() => featureRequests.id, { onDelete: 'cascade' })
+ .notNull(),
+ userId: uuid('user_id')
+ .references(() => users.id, { onDelete: 'cascade' })
+ .notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ featureRequestIdIdx: index('feature_votes_feature_request_id_idx').on(table.featureRequestId),
+ userIdIdx: index('feature_votes_user_id_idx').on(table.userId),
+ uniqueVote: index('feature_votes_unique_idx').on(table.featureRequestId, table.userId),
+ })
+);
+
+// ============================================
+// Folders Table (minimal usage, keep for future)
+// ============================================
+export const folders = pgTable(
+ 'folders',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ userId: uuid('user_id')
+ .references(() => users.id, { onDelete: 'cascade' })
+ .notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ userIdIdx: index('folders_user_id_idx').on(table.userId),
+ })
+);
+
+// ============================================
+// Relations (for Drizzle Relational Queries)
+// ============================================
+export const usersRelations = relations(users, ({ many }) => ({
+ links: many(links),
+ tags: many(tags),
+ notifications: many(notifications),
+ ownedAccounts: many(accounts),
+ ownedWorkspaces: many(workspaces),
+ featureRequests: many(featureRequests),
+ featureVotes: many(featureVotes),
+ folders: many(folders),
+}));
+
+export const linksRelations = relations(links, ({ one, many }) => ({
+ user: one(users, { fields: [links.userId], references: [users.id] }),
+ account: one(accounts, { fields: [links.accountOwner], references: [accounts.id] }),
+ workspace: one(workspaces, { fields: [links.workspaceId], references: [workspaces.id] }),
+ clicks: many(clicks),
+ linkTags: many(linkTags),
+}));
+
+export const clicksRelations = relations(clicks, ({ one }) => ({
+ link: one(links, { fields: [clicks.linkId], references: [links.id] }),
+}));
+
+export const tagsRelations = relations(tags, ({ one, many }) => ({
+ user: one(users, { fields: [tags.userId], references: [users.id] }),
+ linkTags: many(linkTags),
+}));
+
+export const linkTagsRelations = relations(linkTags, ({ one }) => ({
+ link: one(links, { fields: [linkTags.linkId], references: [links.id] }),
+ tag: one(tags, { fields: [linkTags.tagId], references: [tags.id] }),
+}));
+
+export const accountsRelations = relations(accounts, ({ one, many }) => ({
+ owner: one(users, { fields: [accounts.owner], references: [users.id] }),
+ links: many(links),
+}));
+
+export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
+ owner: one(users, { fields: [workspaces.owner], references: [users.id] }),
+ links: many(links),
+}));
+
+export const notificationsRelations = relations(notifications, ({ one }) => ({
+ user: one(users, { fields: [notifications.userId], references: [users.id] }),
+}));
+
+export const featureRequestsRelations = relations(featureRequests, ({ one, many }) => ({
+ user: one(users, { fields: [featureRequests.userId], references: [users.id] }),
+ votes: many(featureVotes),
+}));
+
+export const featureVotesRelations = relations(featureVotes, ({ one }) => ({
+ featureRequest: one(featureRequests, {
+ fields: [featureVotes.featureRequestId],
+ references: [featureRequests.id],
+ }),
+ user: one(users, { fields: [featureVotes.userId], references: [users.id] }),
+}));
+
+export const foldersRelations = relations(folders, ({ one }) => ({
+ user: one(users, { fields: [folders.userId], references: [users.id] }),
+}));
diff --git a/apps/uload/apps/web/src/lib/email.ts b/apps/uload/apps/web/src/lib/email.ts
new file mode 100644
index 000000000..edefe3892
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/email.ts
@@ -0,0 +1,222 @@
+import { Resend } from 'resend';
+import { env } from '$env/dynamic/private';
+import { env as publicEnv } from '$env/dynamic/public';
+
+// Initialize Resend client
+const resend = new Resend(env.RESEND_API_KEY);
+
+const FROM_EMAIL = env.RESEND_FROM_EMAIL || 'noreply@ulo.ad';
+const APP_URL = publicEnv.PUBLIC_APP_URL || 'https://ulo.ad';
+
+/**
+ * Send a team invitation email
+ */
+export async function sendTeamInvitationEmail(
+ recipientEmail: string,
+ inviterName: string,
+ inviteToken: string
+): Promise {
+ try {
+ const inviteUrl = `${APP_URL}/register?invite=${inviteToken}`;
+
+ await resend.emails.send({
+ from: `ulo.ad <${FROM_EMAIL}>`,
+ to: recipientEmail,
+ subject: `${inviterName} hat dich zu seinem Team eingeladen - ulo.ad`,
+ html: `
+
+
+
+
+ 🔗 ulo.ad
+
+
+
+
+
+
+ Du wurdest zum Team eingeladen! 🎉
+
+
+
+ ${inviterName} hat dich eingeladen, seinem Team bei ulo.ad beizutreten.
+ Als Team-Mitglied kannst du Links erstellen und verwalten.
+
+
+
+
+
+ Als Team-Mitglied kannst du:
+
+
+ Links erstellen und verwalten
+ Deine eigenen Links bearbeiten und löschen
+ Mit dem Team zusammenarbeiten
+
+
+
+
+
+
+
+
+
+ Falls der Button nicht funktioniert, kopiere diesen Link:
+
+
+ ${inviteUrl}
+
+
+
+
+
+
+ ⏰ Diese Einladung ist 7 Tage gültig
+
+
+
+
+
+
+
+ Diese Einladung wurde an ${recipientEmail} gesendet.
+
+
+ © ${new Date().getFullYear()} ulo.ad · ulo.ad
+
+
+
`,
+ });
+
+ console.log('[EMAIL] Team invitation sent to:', recipientEmail);
+ return true;
+ } catch (error) {
+ console.error('[EMAIL] Failed to send invitation email:', error);
+ return false;
+ }
+}
+
+/**
+ * Send notification when invitation is accepted
+ */
+export async function sendInvitationAcceptedEmail(
+ ownerEmail: string,
+ memberName: string
+): Promise {
+ try {
+ await resend.emails.send({
+ from: `ulo.ad <${FROM_EMAIL}>`,
+ to: ownerEmail,
+ subject: `${memberName} hat deine Einladung angenommen - ulo.ad`,
+ html: `
+
+
+
+
+ 🔗 ulo.ad
+
+
+
+
+
+
+ Neues Team-Mitglied! 🎊
+
+
+
+ ${memberName} hat deine Einladung angenommen und ist jetzt Teil deines Teams.
+
+
+
+
+
+ ✅ Das Team-Mitglied kann jetzt Links in deinem Account erstellen und verwalten.
+
+
+
+
+
+
+
+
+
+
+ © ${new Date().getFullYear()} ulo.ad · ulo.ad
+
+
+
`,
+ });
+
+ console.log('[EMAIL] Acceptance notification sent to:', ownerEmail);
+ return true;
+ } catch (error) {
+ console.error('[EMAIL] Failed to send acceptance notification:', error);
+ return false;
+ }
+}
+
+/**
+ * Send welcome email to new users
+ */
+export async function sendWelcomeEmail(to: string, username: string): Promise {
+ try {
+ await resend.emails.send({
+ from: `ulo.ad <${FROM_EMAIL}>`,
+ to,
+ subject: 'Willkommen bei ulo.ad!',
+ html: `
+
+
+
+ 🔗 ulo.ad
+
+
+
+
+
Willkommen, ${username}!
+
Danke, dass du bei ulo.ad dabei bist. Wir freuen uns, dich an Bord zu haben.
+
Mit ulo.ad kannst du:
+
+ URLs kürzen und anpassen
+ Click-Analytics verfolgen
+ Links mit Tags und Workspaces organisieren
+ QR-Codes generieren
+ Ablaufdaten und Click-Limits setzen
+
+
+
+
+
+
+ © ${new Date().getFullYear()} ulo.ad
+
+
+
`,
+ });
+
+ return true;
+ } catch (error) {
+ console.error('[EMAIL] Failed to send welcome email:', error);
+ return false;
+ }
+}
diff --git a/apps/uload/apps/web/src/lib/gdpr/compliance.ts b/apps/uload/apps/web/src/lib/gdpr/compliance.ts
new file mode 100644
index 000000000..f3d741bc2
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/gdpr/compliance.ts
@@ -0,0 +1,422 @@
+// GDPR Compliance Implementierung für uLoad
+// Datenschutz-Grundverordnung (DSGVO) Konformität
+
+export interface GDPRConsent {
+ necessary: boolean; // Immer true, technisch erforderlich
+ analytics: boolean;
+ marketing: boolean;
+ preferences: boolean;
+ timestamp: string;
+ version: string;
+}
+
+export interface DataProcessingPurpose {
+ id: string;
+ name: string;
+ description: string;
+ legalBasis:
+ | 'consent'
+ | 'contract'
+ | 'legal_obligation'
+ | 'vital_interests'
+ | 'public_task'
+ | 'legitimate_interests';
+ dataTypes: string[];
+ retention: string;
+ required: boolean;
+}
+
+// GDPR-konforme Datenverarbeitungszwecke für uLoad
+export const DATA_PROCESSING_PURPOSES: DataProcessingPurpose[] = [
+ {
+ id: 'account_management',
+ name: 'Account-Verwaltung',
+ description: 'Bereitstellung und Verwaltung Ihres Benutzerkontos',
+ legalBasis: 'contract',
+ dataTypes: ['email', 'username', 'password_hash', 'profile_data'],
+ retention: 'Bis zur Kontolöschung',
+ required: true,
+ },
+ {
+ id: 'link_service',
+ name: 'Link-Verkürrungs-Service',
+ description: 'Erstellung und Verwaltung von kurzen Links',
+ legalBasis: 'contract',
+ dataTypes: ['original_urls', 'short_codes', 'link_metadata'],
+ retention: 'Bis zur manuellen Löschung oder Kontolöschung',
+ required: true,
+ },
+ {
+ id: 'click_analytics',
+ name: 'Click-Analytics',
+ description: 'Anonyme Analyse von Link-Klicks für Statistiken',
+ legalBasis: 'legitimate_interests',
+ dataTypes: ['anonymized_ip', 'user_agent', 'referer', 'timestamp'],
+ retention: '12 Monate',
+ required: false,
+ },
+ {
+ id: 'security',
+ name: 'Sicherheit und Betrug-Prävention',
+ description: 'Schutz vor Missbrauch und Sicherheit der Plattform',
+ legalBasis: 'legitimate_interests',
+ dataTypes: ['ip_address', 'user_agent', 'access_logs'],
+ retention: '6 Monate',
+ required: true,
+ },
+ {
+ id: 'communication',
+ name: 'Service-Kommunikation',
+ description: 'Wichtige Mitteilungen zum Service (Updates, Sicherheit)',
+ legalBasis: 'contract',
+ dataTypes: ['email', 'communication_preferences'],
+ retention: 'Bis zur Kontolöschung',
+ required: true,
+ },
+ {
+ id: 'marketing',
+ name: 'Marketing und Newsletter',
+ description: 'Produktneuigkeiten und Marketing-Kommunikation',
+ legalBasis: 'consent',
+ dataTypes: ['email', 'usage_patterns', 'preferences'],
+ retention: 'Bis zum Widerruf der Einwilligung',
+ required: false,
+ },
+ {
+ id: 'analytics',
+ name: 'Website-Analytics',
+ description: 'Analyse der Website-Nutzung zur Verbesserung',
+ legalBasis: 'consent',
+ dataTypes: ['anonymized_usage_data', 'page_views', 'session_data'],
+ retention: '14 Monate',
+ required: false,
+ },
+];
+
+// Standard GDPR Consent
+export const DEFAULT_CONSENT: GDPRConsent = {
+ necessary: true,
+ analytics: false,
+ marketing: false,
+ preferences: false,
+ timestamp: new Date().toISOString(),
+ version: '1.0',
+};
+
+// GDPR Consent Manager
+export class GDPRManager {
+ private static readonly CONSENT_KEY = 'gdpr_consent';
+ private static readonly CONSENT_VERSION = '1.0';
+
+ // Aktuelle Einwilligung laden
+ static getConsent(): GDPRConsent | null {
+ if (typeof localStorage === 'undefined') return null;
+
+ try {
+ const stored = localStorage.getItem(this.CONSENT_KEY);
+ if (!stored) return null;
+
+ const consent = JSON.parse(stored) as GDPRConsent;
+
+ // Prüfe Version - bei Änderungen neue Einwilligung erforderlich
+ if (consent.version !== this.CONSENT_VERSION) {
+ this.clearConsent();
+ return null;
+ }
+
+ return consent;
+ } catch (error) {
+ console.error('Error loading GDPR consent:', error);
+ return null;
+ }
+ }
+
+ // Einwilligung speichern
+ static setConsent(consent: Partial): void {
+ if (typeof localStorage === 'undefined') return;
+
+ const fullConsent: GDPRConsent = {
+ ...DEFAULT_CONSENT,
+ ...consent,
+ timestamp: new Date().toISOString(),
+ version: this.CONSENT_VERSION,
+ };
+
+ try {
+ localStorage.setItem(this.CONSENT_KEY, JSON.stringify(fullConsent));
+
+ // Event für andere Teile der App
+ window.dispatchEvent(
+ new CustomEvent('gdpr:consent-updated', {
+ detail: fullConsent,
+ })
+ );
+
+ console.log('GDPR consent updated:', fullConsent);
+ } catch (error) {
+ console.error('Error saving GDPR consent:', error);
+ }
+ }
+
+ // Einwilligung löschen
+ static clearConsent(): void {
+ if (typeof localStorage === 'undefined') return;
+
+ localStorage.removeItem(this.CONSENT_KEY);
+
+ window.dispatchEvent(new CustomEvent('gdpr:consent-cleared'));
+ console.log('GDPR consent cleared');
+ }
+
+ // Prüfe ob Einwilligung erforderlich ist
+ static needsConsent(): boolean {
+ const consent = this.getConsent();
+ return consent === null;
+ }
+
+ // Prüfe spezifische Einwilligung
+ static hasConsent(type: keyof Omit): boolean {
+ const consent = this.getConsent();
+ if (!consent) return type === 'necessary'; // Nur notwendige Cookies ohne Einwilligung
+
+ return consent[type];
+ }
+
+ // Benutzerrechte verwalten
+ static async exerciseUserRights(request: UserRightRequest): Promise {
+ switch (request.type) {
+ case 'access':
+ return this.handleDataAccess(request);
+ case 'rectification':
+ return this.handleDataRectification(request);
+ case 'erasure':
+ return this.handleDataErasure(request);
+ case 'portability':
+ return this.handleDataPortability(request);
+ case 'restriction':
+ return this.handleProcessingRestriction(request);
+ case 'objection':
+ return this.handleProcessingObjection(request);
+ default:
+ throw new Error('Unknown user right request');
+ }
+ }
+
+ // Recht auf Auskunft (Art. 15 DSGVO)
+ private static async handleDataAccess(request: UserRightRequest): Promise {
+ // Sammle alle Benutzerdaten
+ const userData = {
+ account: {
+ email: request.userEmail,
+ created: request.accountCreated,
+ lastLogin: request.lastLogin,
+ },
+ links: request.userLinks || [],
+ analytics: request.userAnalytics || [],
+ consent: this.getConsent(),
+ purposes: DATA_PROCESSING_PURPOSES.filter((p) => p.required || this.hasConsent(p.id as any)),
+ };
+
+ return {
+ success: true,
+ type: 'access',
+ data: userData,
+ message: 'Ihre personenbezogenen Daten wurden zusammengestellt',
+ };
+ }
+
+ // Recht auf Berichtigung (Art. 16 DSGVO)
+ private static async handleDataRectification(
+ request: UserRightRequest
+ ): Promise {
+ // In einer echten Implementation würde hier eine API-Anfrage an den Server gehen
+ return {
+ success: true,
+ type: 'rectification',
+ message: 'Ihr Antrag auf Datenberichtigung wurde eingereicht',
+ };
+ }
+
+ // Recht auf Löschung (Art. 17 DSGVO)
+ private static async handleDataErasure(request: UserRightRequest): Promise {
+ // Lokale Consent-Daten löschen
+ this.clearConsent();
+
+ return {
+ success: true,
+ type: 'erasure',
+ message: 'Ihr Antrag auf Datenlöschung wurde eingereicht',
+ };
+ }
+
+ // Recht auf Datenübertragbarkeit (Art. 20 DSGVO)
+ private static async handleDataPortability(
+ request: UserRightRequest
+ ): Promise {
+ const exportData = {
+ links: request.userLinks || [],
+ analytics: request.userAnalytics || [],
+ profile: request.userProfile || {},
+ exportDate: new Date().toISOString(),
+ format: 'JSON',
+ };
+
+ return {
+ success: true,
+ type: 'portability',
+ data: exportData,
+ message: 'Ihre Daten wurden für den Export vorbereitet',
+ };
+ }
+
+ // Recht auf Einschränkung (Art. 18 DSGVO)
+ private static async handleProcessingRestriction(
+ request: UserRightRequest
+ ): Promise {
+ return {
+ success: true,
+ type: 'restriction',
+ message: 'Ihr Antrag auf Verarbeitungseinschränkung wurde eingereicht',
+ };
+ }
+
+ // Widerspruchsrecht (Art. 21 DSGVO)
+ private static async handleProcessingObjection(
+ request: UserRightRequest
+ ): Promise {
+ // Analytics und Marketing deaktivieren
+ this.setConsent({
+ ...this.getConsent(),
+ analytics: false,
+ marketing: false,
+ });
+
+ return {
+ success: true,
+ type: 'objection',
+ message: 'Ihr Widerspruch wurde verarbeitet',
+ };
+ }
+}
+
+// Interfaces für Benutzerrechte
+export interface UserRightRequest {
+ type: 'access' | 'rectification' | 'erasure' | 'portability' | 'restriction' | 'objection';
+ userEmail: string;
+ accountCreated?: string;
+ lastLogin?: string;
+ userLinks?: any[];
+ userAnalytics?: any[];
+ userProfile?: any;
+ reason?: string;
+}
+
+export interface UserRightResponse {
+ success: boolean;
+ type: string;
+ data?: any;
+ message: string;
+ error?: string;
+}
+
+// Cookie-Banner Utilities
+export function shouldShowCookieBanner(): boolean {
+ return GDPRManager.needsConsent();
+}
+
+export function acceptAllCookies(): void {
+ GDPRManager.setConsent({
+ necessary: true,
+ analytics: true,
+ marketing: true,
+ preferences: true,
+ });
+}
+
+export function acceptNecessaryOnly(): void {
+ GDPRManager.setConsent({
+ necessary: true,
+ analytics: false,
+ marketing: false,
+ preferences: false,
+ });
+}
+
+// Data Processing Record (Art. 30 DSGVO)
+export function generateProcessingRecord(): any {
+ return {
+ controller: {
+ name: 'uLoad',
+ contact: 'privacy@ulo.ad',
+ representative: 'Till Schneider',
+ dpo: null, // Falls kein Datenschutzbeauftragter erforderlich
+ },
+ purposes: DATA_PROCESSING_PURPOSES,
+ categories: {
+ dataSubjects: ['users', 'visitors'],
+ personalData: ['identification', 'contact', 'usage', 'technical'],
+ recipients: ['hosting_provider', 'analytics_provider', 'payment_provider'],
+ transfers: ['within_eu'],
+ },
+ retention: {
+ criteria: 'Purpose-based retention',
+ periods: DATA_PROCESSING_PURPOSES.map((p) => ({
+ purpose: p.name,
+ period: p.retention,
+ })),
+ },
+ security: {
+ measures: ['encryption', 'access_control', 'regular_backups', 'monitoring'],
+ certifications: [],
+ },
+ lastUpdated: new Date().toISOString(),
+ };
+}
+
+// Anonymisierung von IP-Adressen (für Analytics)
+export function anonymizeIP(ip: string): string {
+ if (ip.includes(':')) {
+ // IPv6 - entferne die letzten 80 Bits
+ const parts = ip.split(':');
+ return parts.slice(0, 5).join(':') + '::';
+ } else {
+ // IPv4 - entferne das letzte Oktett
+ const parts = ip.split('.');
+ return parts.slice(0, 3).join('.') + '.0';
+ }
+}
+
+// Daten-Minimierung prüfen
+export function isDataMinimal(dataCollection: any): boolean {
+ const requiredFields = ['email', 'username'];
+ const optionalFields = ['name', 'bio', 'website'];
+ const collectedFields = Object.keys(dataCollection);
+
+ // Prüfe ob nur notwendige und explizit gewünschte Felder gesammelt werden
+ const unnecessary = collectedFields.filter(
+ (field) => !requiredFields.includes(field) && !optionalFields.includes(field)
+ );
+
+ return unnecessary.length === 0;
+}
+
+// Legal Basis Validation
+export function validateLegalBasis(
+ purpose: string,
+ hasConsent: boolean,
+ isRequired: boolean
+): boolean {
+ const purposeConfig = DATA_PROCESSING_PURPOSES.find((p) => p.id === purpose);
+ if (!purposeConfig) return false;
+
+ switch (purposeConfig.legalBasis) {
+ case 'consent':
+ return hasConsent;
+ case 'contract':
+ return isRequired;
+ case 'legitimate_interests':
+ return true; // Interessenabwägung bereits durchgeführt
+ default:
+ return false;
+ }
+}
diff --git a/apps/uload/apps/web/src/lib/i18n/index.ts b/apps/uload/apps/web/src/lib/i18n/index.ts
new file mode 100644
index 000000000..7e51106fa
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/i18n/index.ts
@@ -0,0 +1,60 @@
+import { browser } from '$app/environment';
+import { init, register, locale, waitLocale } from 'svelte-i18n';
+
+// Register all available locales
+register('de', () => import('./locales/de.json'));
+register('en', () => import('./locales/en.json'));
+register('it', () => import('./locales/it.json'));
+register('fr', () => import('./locales/fr.json'));
+register('es', () => import('./locales/es.json'));
+
+// List of supported locales
+export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const;
+export type SupportedLocale = (typeof supportedLocales)[number];
+
+// Default locale
+const defaultLocale = 'en';
+
+// Get initial locale from browser or localStorage
+function getInitialLocale(): SupportedLocale {
+ if (browser) {
+ // Check localStorage first
+ const stored = localStorage.getItem('locale');
+ if (stored && supportedLocales.includes(stored as SupportedLocale)) {
+ return stored as SupportedLocale;
+ }
+
+ // Fall back to browser language
+ const browserLang = navigator.language.split('-')[0];
+ if (supportedLocales.includes(browserLang as SupportedLocale)) {
+ return browserLang as SupportedLocale;
+ }
+ }
+
+ return defaultLocale;
+}
+
+// Initialize i18n at module scope (required for SSR)
+init({
+ fallbackLocale: defaultLocale,
+ initialLocale: getInitialLocale(),
+});
+
+// Also export initI18n for backwards compatibility
+export function initI18n() {
+ init({
+ fallbackLocale: defaultLocale,
+ initialLocale: getInitialLocale(),
+ });
+}
+
+// Set locale and persist to localStorage
+export function setLocale(newLocale: SupportedLocale) {
+ locale.set(newLocale);
+ if (browser) {
+ localStorage.setItem('locale', newLocale);
+ }
+}
+
+// Wait for locale to be loaded (useful for SSR)
+export { waitLocale };
diff --git a/apps/uload/apps/web/src/lib/i18n/locales/de.json b/apps/uload/apps/web/src/lib/i18n/locales/de.json
new file mode 100644
index 000000000..bcee7d5aa
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/i18n/locales/de.json
@@ -0,0 +1,28 @@
+{
+ "nav_login": "Anmelden",
+ "nav_register": "Registrieren",
+ "nav_dashboard": "Dashboard",
+ "nav_folders": "Ordner",
+ "nav_profile": "Profil",
+ "nav_logout": "Abmelden",
+ "home_title": "Links intelligenter teilen",
+ "home_subtitle": "Erstelle verkürzte Links mit QR-Codes, benutzerdefinierten Namen und Analysen",
+ "home_url_label_qr": "URL zum Kodieren",
+ "home_url_label": "URL zum Kürzen",
+ "home_title_label": "Titel",
+ "home_title_placeholder": "Gib deinem Link einen Namen",
+ "home_description_label": "Beschreibung",
+ "home_description_placeholder": "Füge eine Beschreibung hinzu (optional)",
+ "home_expires_label": "Ablauf",
+ "home_expires_placeholder": "z.B. 7 Tage, 1 Monat",
+ "home_max_clicks_label": "Max. Klicks",
+ "home_max_clicks_placeholder": "Anzahl der Klicks begrenzen",
+ "home_password_label": "Passwort",
+ "home_password_placeholder": "Mit Passwort schützen",
+ "home_guest_info": "Du verwendest uload als Gast",
+ "auth_modal_signin": "Anmelden",
+ "home_guest_signin_hint": "um auf erweiterte Funktionen zuzugreifen",
+ "home_processing": "Verarbeitung...",
+ "home_submit_button_qr": "QR-Code generieren",
+ "home_submit_button": "Link erstellen"
+}
diff --git a/apps/uload/apps/web/src/lib/i18n/locales/en.json b/apps/uload/apps/web/src/lib/i18n/locales/en.json
new file mode 100644
index 000000000..d65d949e4
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/i18n/locales/en.json
@@ -0,0 +1,144 @@
+{
+ "nav_login": "Login",
+ "nav_register": "Register",
+ "nav_dashboard": "Dashboard",
+ "nav_folders": "Folders",
+ "nav_profile": "Profile",
+ "nav_logout": "Logout",
+ "nav_pricing": "Pricing",
+
+ "home_title": "Share Links Smarter",
+ "home_subtitle": "Create shortened links with QR codes, custom names, and analytics",
+ "home_url_label_qr": "URL to encode",
+ "home_url_label": "URL to shorten",
+ "home_title_label": "Title",
+ "home_title_placeholder": "Give your link a name",
+ "home_description_label": "Description",
+ "home_description_placeholder": "Add a description (optional)",
+ "home_expires_label": "Expiration",
+ "home_expires_placeholder": "e.g., 7 days, 1 month",
+ "home_max_clicks_label": "Max clicks",
+ "home_max_clicks_placeholder": "Limit number of clicks",
+ "home_password_label": "Password",
+ "home_password_placeholder": "Protect with password",
+ "home_guest_info": "You're using uload as a guest",
+ "home_guest_signin_hint": "to access advanced features",
+ "home_processing": "Processing...",
+ "home_submit_button_qr": "Generate QR Code",
+ "home_submit_button": "Create Link",
+
+ "auth_modal_signin": "Sign in",
+ "auth_sign_in": "Sign In",
+ "auth_login_button": "Login",
+ "auth_login_button_loading": "Logging in...",
+ "auth_register_button": "Register",
+ "auth_register_button_loading": "Creating account...",
+ "auth_email_label": "Email",
+ "auth_email_placeholder": "Enter your email",
+ "auth_email_address_label": "Email Address",
+ "auth_password_label": "Password",
+ "auth_password_confirm_label": "Confirm Password",
+ "auth_forgot_password": "Forgot password?",
+ "auth_no_account": "Don't have an account?",
+ "auth_have_account": "Already have an account?",
+ "auth_create_account": "Create Account",
+ "auth_create_account_title": "Create Account",
+ "auth_create_account_subtitle": "Join us to start shortening links",
+ "auth_welcome_back": "Welcome Back",
+ "auth_welcome_back_subtitle": "Sign in to continue",
+ "auth_back_to_login": "Back to login",
+ "auth_go_to_login": "Go to login",
+ "auth_remember_password": "Remember your password?",
+ "auth_username_auto": "Username will be generated automatically",
+ "auth_registration_tip": "You'll receive a verification email",
+ "auth_registration_success": "Registration successful!",
+ "auth_registration_success_message": "Please check your email to verify your account.",
+
+ "auth_reset_password_title": "Reset Password",
+ "auth_reset_password_subtitle": "Enter your email to receive a reset link",
+ "auth_reset_password_button": "Reset Password",
+ "auth_reset_password_button_loading": "Resetting...",
+ "auth_send_reset_button": "Send Reset Link",
+ "auth_send_reset_button_loading": "Sending...",
+ "auth_reset_email_sent_title": "Email Sent",
+ "auth_reset_email_sent_message": "Check your inbox for the password reset link.",
+ "auth_request_new_reset_link": "Request new link",
+
+ "auth_set_new_password_title": "Set New Password",
+ "auth_set_new_password_subtitle": "Enter your new password below",
+ "auth_new_password_label": "New Password",
+ "auth_new_password_placeholder": "Enter new password",
+ "auth_confirm_new_password_label": "Confirm New Password",
+ "auth_confirm_new_password_placeholder": "Confirm new password",
+ "auth_password_reset_success": "Password Reset",
+ "auth_password_reset_success_message": "Your password has been successfully reset.",
+
+ "auth_invalid_reset_link": "Invalid Reset Link",
+ "auth_invalid_reset_link_message": "This password reset link is invalid or has expired.",
+ "auth_invalid_verification_link": "Invalid Verification Link",
+ "auth_invalid_verification_link_message": "This verification link is invalid or has expired.",
+ "auth_verification_link_expired": "Link Expired",
+ "auth_verification_link_expired_message": "This verification link has expired. Please request a new one.",
+ "auth_email_verified": "Email Verified",
+ "auth_email_verified_message": "Your email has been successfully verified.",
+ "auth_email_already_verified": "Already Verified",
+ "auth_email_already_verified_message": "Your email is already verified.",
+ "auth_email_already_verified_notify": "Already verified",
+ "auth_email_already_verified_notify_desc": "Your email was already verified. You can log in now.",
+ "auth_token_expired_notify": "Session Expired",
+ "auth_token_expired_notify_desc": "Your session has expired. Please log in again.",
+
+ "auth_add_account": "Add Account",
+ "auth_add_account_info": "Add another account to quickly switch between them",
+ "auth_add_account_subtitle": "Sign in with another account",
+ "auth_add_account_switch_info": "You can switch between accounts anytime",
+
+ "account_my_account": "My Account",
+ "account_add_account": "Add Account",
+ "account_team_accounts": "Team Accounts",
+ "account_no_team_accounts": "No team accounts",
+ "account_team_invite_info": "Invite team members to collaborate",
+ "account_team_member": "Team Member",
+
+ "workspace_switch": "Switch Workspace",
+ "workspace_personal": "Personal",
+ "workspace_create": "Create Workspace",
+
+ "hero_control_headline": "Share Links Smarter",
+ "hero_control_subheadline": "Create shortened links with analytics and QR codes",
+ "hero_control_cta": "Get Started",
+ "hero_free_text": "Free to start",
+ "hero_trust_badge_": "Trusted by thousands",
+ "hero_a": "Hero A",
+ "hero_b": "Hero B",
+ "hero_c": "Hero C",
+
+ "toast_login_success": "Login successful",
+ "toast_login_error": "Login failed",
+ "toast_logout_success": "Logged out successfully",
+ "toast_register_success": "Account created successfully",
+ "toast_link_created": "Link created successfully",
+ "toast_link_updated": "Link updated successfully",
+ "toast_link_deleted": "Link deleted successfully",
+ "toast_link_copied": "Link copied to clipboard",
+ "toast_profile_updated": "Profile updated successfully",
+ "toast_avatar_uploaded": "Avatar uploaded successfully",
+ "toast_password_changed": "Password changed successfully",
+ "toast_password_reset_sent": "Password reset email sent",
+ "toast_email_verified": "Email verified successfully",
+ "toast_session_expired": "Session expired",
+ "toast_session_expired_desc": "Please log in again to continue.",
+ "toast_network_error": "Network error",
+ "toast_network_error_desc": "Please check your connection and try again.",
+ "toast_permission_denied": "Permission denied",
+ "toast_payment_failed": "Payment failed",
+ "toast_payment_failed_desc": "Please try again or use a different payment method.",
+ "toast_subscription_upgraded": "Subscription upgraded",
+ "toast_subscription_cancelled": "Subscription cancelled",
+ "toast_unsupported_format": "Unsupported format",
+
+ "error_link_creation": "Failed to create links",
+ "error_link_creation_single": "Failed to create link",
+ "error_password_change": "Failed to change password",
+ "error_save": "Failed to save changes"
+}
diff --git a/apps/uload/apps/web/src/lib/i18n/locales/es.json b/apps/uload/apps/web/src/lib/i18n/locales/es.json
new file mode 100644
index 000000000..51e517dad
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/i18n/locales/es.json
@@ -0,0 +1,28 @@
+{
+ "nav_login": "Iniciar sesión",
+ "nav_register": "Registrarse",
+ "nav_dashboard": "Panel",
+ "nav_folders": "Carpetas",
+ "nav_profile": "Perfil",
+ "nav_logout": "Cerrar sesión",
+ "home_title": "Comparte Enlaces de Forma Inteligente",
+ "home_subtitle": "Crea enlaces acortados con códigos QR, nombres personalizados y análisis",
+ "home_url_label_qr": "URL para codificar",
+ "home_url_label": "URL para acortar",
+ "home_title_label": "Título",
+ "home_title_placeholder": "Dale un nombre a tu enlace",
+ "home_description_label": "Descripción",
+ "home_description_placeholder": "Añadir una descripción (opcional)",
+ "home_expires_label": "Vencimiento",
+ "home_expires_placeholder": "ej., 7 días, 1 mes",
+ "home_max_clicks_label": "Clics máximos",
+ "home_max_clicks_placeholder": "Limitar número de clics",
+ "home_password_label": "Contraseña",
+ "home_password_placeholder": "Proteger con contraseña",
+ "home_guest_info": "Estás usando uload como invitado",
+ "auth_modal_signin": "Iniciar sesión",
+ "home_guest_signin_hint": "para acceder a funciones avanzadas",
+ "home_processing": "Procesando...",
+ "home_submit_button_qr": "Generar Código QR",
+ "home_submit_button": "Crear Enlace"
+}
diff --git a/apps/uload/apps/web/src/lib/i18n/locales/fr.json b/apps/uload/apps/web/src/lib/i18n/locales/fr.json
new file mode 100644
index 000000000..fbbcfdc70
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/i18n/locales/fr.json
@@ -0,0 +1,28 @@
+{
+ "nav_login": "Connexion",
+ "nav_register": "S'inscrire",
+ "nav_dashboard": "Tableau de bord",
+ "nav_folders": "Dossiers",
+ "nav_profile": "Profil",
+ "nav_logout": "Déconnexion",
+ "home_title": "Partagez des Liens Intelligemment",
+ "home_subtitle": "Créez des liens raccourcis avec codes QR, noms personnalisés et analyses",
+ "home_url_label_qr": "URL à encoder",
+ "home_url_label": "URL à raccourcir",
+ "home_title_label": "Titre",
+ "home_title_placeholder": "Donnez un nom à votre lien",
+ "home_description_label": "Description",
+ "home_description_placeholder": "Ajouter une description (optionnel)",
+ "home_expires_label": "Expiration",
+ "home_expires_placeholder": "ex., 7 jours, 1 mois",
+ "home_max_clicks_label": "Clics maximum",
+ "home_max_clicks_placeholder": "Limiter le nombre de clics",
+ "home_password_label": "Mot de passe",
+ "home_password_placeholder": "Protéger avec mot de passe",
+ "home_guest_info": "Vous utilisez uload en tant qu'invité",
+ "auth_modal_signin": "Se connecter",
+ "home_guest_signin_hint": "pour accéder aux fonctionnalités avancées",
+ "home_processing": "Traitement...",
+ "home_submit_button_qr": "Générer Code QR",
+ "home_submit_button": "Créer Lien"
+}
diff --git a/apps/uload/apps/web/src/lib/i18n/locales/it.json b/apps/uload/apps/web/src/lib/i18n/locales/it.json
new file mode 100644
index 000000000..528b4ecb7
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/i18n/locales/it.json
@@ -0,0 +1,28 @@
+{
+ "nav_login": "Accedi",
+ "nav_register": "Registrati",
+ "nav_dashboard": "Dashboard",
+ "nav_folders": "Cartelle",
+ "nav_profile": "Profilo",
+ "nav_logout": "Esci",
+ "home_title": "Condividi Link in Modo Intelligente",
+ "home_subtitle": "Crea link abbreviati con codici QR, nomi personalizzati e analisi",
+ "home_url_label_qr": "URL da codificare",
+ "home_url_label": "URL da abbreviare",
+ "home_title_label": "Titolo",
+ "home_title_placeholder": "Dai un nome al tuo link",
+ "home_description_label": "Descrizione",
+ "home_description_placeholder": "Aggiungi una descrizione (opzionale)",
+ "home_expires_label": "Scadenza",
+ "home_expires_placeholder": "es., 7 giorni, 1 mese",
+ "home_max_clicks_label": "Click massimi",
+ "home_max_clicks_placeholder": "Limita il numero di click",
+ "home_password_label": "Password",
+ "home_password_placeholder": "Proteggi con password",
+ "home_guest_info": "Stai usando uload come ospite",
+ "auth_modal_signin": "Accedi",
+ "home_guest_signin_hint": "per accedere alle funzionalità avanzate",
+ "home_processing": "Elaborazione...",
+ "home_submit_button_qr": "Genera Codice QR",
+ "home_submit_button": "Crea Link"
+}
diff --git a/apps/uload/apps/web/src/lib/index.ts b/apps/uload/apps/web/src/lib/index.ts
new file mode 100644
index 000000000..856f2b6c3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte b/apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte
new file mode 100644
index 000000000..a63a8eaf5
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/layouts/BlogLayout.svelte
@@ -0,0 +1,263 @@
+
+
+
+ {seo.title || title} | uload Blog
+
+
+
+
+
+
+ {#each tags as tag}
+
+ {/each}
+ {#if image}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if series}
+
+ Serie: {series}
+
+ {/if}
+
+ {title}
+
+
+
+ {formattedDate}
+
+
•
+
+ {category}
+
+
•
+
{readingTime} Min. Lesezeit
+
+
+ {#if tags.length > 0}
+
+ {/if}
+
+ {#if image}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ {#if authorData}
+
+
Über den Autor
+
+ {#if authorData.avatar}
+
+ {/if}
+
+
{authorData.name}
+ {#if authorData.bio}
+
{authorData.bio}
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte b/apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte
new file mode 100644
index 000000000..d66520547
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/layouts/DefaultLayout.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {#if title}
+ {title} | uload
+ {/if}
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/lib/locale.ts b/apps/uload/apps/web/src/lib/locale.ts
new file mode 100644
index 000000000..37bfd7960
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/locale.ts
@@ -0,0 +1,42 @@
+import { browser } from '$app/environment';
+import { locale } from 'svelte-i18n';
+import { get } from 'svelte/store';
+import '$lib/i18n'; // Initialize i18n
+
+export function initLocale() {
+ if (browser) {
+ const savedLang = localStorage.getItem('preferred-language');
+ const browserLang = navigator.language.split('-')[0];
+ const supportedLangs = ['en', 'de', 'it', 'fr', 'es'];
+
+ let targetLang = 'en'; // default
+
+ if (savedLang && supportedLangs.includes(savedLang)) {
+ targetLang = savedLang;
+ } else if (supportedLangs.includes(browserLang)) {
+ targetLang = browserLang;
+ }
+
+ try {
+ locale.set(targetLang);
+ } catch (e) {
+ console.warn('Failed to set locale:', e);
+ locale.set('en');
+ }
+ }
+}
+
+export function getCurrentLocale(): string {
+ try {
+ return get(locale) || 'en';
+ } catch {
+ return 'en';
+ }
+}
+
+export function setCurrentLocale(lang: string) {
+ locale.set(lang);
+ if (browser) {
+ localStorage.setItem('preferred-language', lang);
+ }
+}
diff --git a/apps/uload/apps/web/src/lib/pwa.ts b/apps/uload/apps/web/src/lib/pwa.ts
new file mode 100644
index 000000000..cb8829d41
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/pwa.ts
@@ -0,0 +1,172 @@
+// PWA Installation und Service Worker Management
+import { browser } from '$app/environment';
+import { writable, get } from 'svelte/store';
+
+// PWA Installation State - using Svelte stores for SSR compatibility
+export const deferredPromptStore = writable(null);
+export const isInstallableStore = writable(false);
+export const isInstalledStore = writable(false);
+export const isStandaloneStore = writable(false);
+
+// Service Worker Registration
+export const serviceWorkerRegistrationStore = writable(null);
+export const isOfflineStore = writable(false);
+
+// Export getters for convenience
+export const getDeferredPrompt = () => get(deferredPromptStore);
+export const getIsInstallable = () => get(isInstallableStore);
+export const getIsInstalled = () => get(isInstalledStore);
+export const getIsStandalone = () => get(isStandaloneStore);
+export const getServiceWorkerRegistration = () => get(serviceWorkerRegistrationStore);
+export const getIsOffline = () => get(isOfflineStore);
+
+if (browser) {
+ // Check if app is already installed (standalone mode)
+ const standalone =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ (window.navigator as any).standalone ||
+ document.referrer.includes('android-app://');
+ isStandaloneStore.set(standalone);
+
+ // Listen for beforeinstallprompt event
+ window.addEventListener('beforeinstallprompt', (e) => {
+ console.log('PWA: Install prompt available');
+ e.preventDefault();
+ deferredPromptStore.set(e);
+ isInstallableStore.set(true);
+ });
+
+ // Listen for app installation
+ window.addEventListener('appinstalled', () => {
+ console.log('PWA: App installed');
+ isInstalledStore.set(true);
+ isInstallableStore.set(false);
+ deferredPromptStore.set(null);
+ });
+
+ // Online/Offline status tracking
+ const updateOnlineStatus = () => {
+ isOfflineStore.set(!navigator.onLine);
+ };
+
+ window.addEventListener('online', updateOnlineStatus);
+ window.addEventListener('offline', updateOnlineStatus);
+ updateOnlineStatus();
+}
+
+// Install PWA function
+export async function installPWA(): Promise {
+ const deferredPrompt = get(deferredPromptStore);
+
+ if (!deferredPrompt) {
+ console.log('PWA: No install prompt available');
+ return false;
+ }
+
+ // Show the install prompt
+ deferredPrompt.prompt();
+
+ // Wait for the user's response
+ const { outcome } = await deferredPrompt.userChoice;
+
+ console.log(`PWA: User response to install prompt: ${outcome}`);
+
+ // Clear the deferred prompt
+ deferredPromptStore.set(null);
+ isInstallableStore.set(false);
+
+ if (outcome === 'accepted') {
+ isInstalledStore.set(true);
+ return true;
+ }
+
+ return false;
+}
+
+// Register Service Worker
+export async function registerServiceWorker(): Promise {
+ if (!browser || !('serviceWorker' in navigator)) {
+ console.log('PWA: Service Worker not supported');
+ return null;
+ }
+
+ try {
+ const registration = await navigator.serviceWorker.register('/sw.js', {
+ scope: '/',
+ });
+
+ console.log('PWA: Service Worker registered', registration);
+ serviceWorkerRegistrationStore.set(registration);
+
+ // Check for updates on focus
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) {
+ registration.update();
+ }
+ });
+
+ // Handle updates
+ registration.addEventListener('updatefound', () => {
+ const newWorker = registration.installing;
+ if (newWorker) {
+ newWorker.addEventListener('statechange', () => {
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
+ // New content available
+ console.log('PWA: New content available');
+ // You might want to show a notification to the user
+ }
+ });
+ }
+ });
+
+ return registration;
+ } catch (error) {
+ console.error('PWA: Service Worker registration failed:', error);
+ return null;
+ }
+}
+
+// Initialize PWA features
+export function initializePWA() {
+ if (!browser) return;
+
+ // Register service worker
+ registerServiceWorker();
+
+ // Additional PWA features can be initialized here
+ console.log('PWA: Initialized');
+}
+
+// Check if update is available
+export async function checkForUpdates(): Promise {
+ const registration = get(serviceWorkerRegistrationStore);
+
+ if (!registration) {
+ return false;
+ }
+
+ try {
+ await registration.update();
+ return registration.waiting !== null;
+ } catch (error) {
+ console.error('PWA: Error checking for updates:', error);
+ return false;
+ }
+}
+
+// Skip waiting and activate new service worker
+export async function activateUpdate(): Promise {
+ const registration = get(serviceWorkerRegistrationStore);
+
+ if (!registration || !registration.waiting) {
+ return;
+ }
+
+ // Tell the waiting service worker to activate
+ registration.waiting.postMessage({ type: 'SKIP_WAITING' });
+
+ // Reload the page once the new service worker is activated
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ window.location.reload();
+ });
+}
diff --git a/apps/uload/apps/web/src/lib/qrcode.ts b/apps/uload/apps/web/src/lib/qrcode.ts
new file mode 100644
index 000000000..b216bd946
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/qrcode.ts
@@ -0,0 +1,175 @@
+export type QRCodeColor = 'black' | 'white' | 'gold';
+export type QRCodeFormat = 'png' | 'svg' | 'jpg';
+export type QRCodeRotation = 0 | 45 | 90 | 135 | 180 | 225 | 270 | 315;
+
+export const QR_COLORS = {
+ black: { color: '000000', bg: 'ffffff' },
+ white: { color: 'ffffff', bg: '000000' },
+ gold: { color: 'f8d62b', bg: '000000' },
+};
+
+export function generateQRCodeURL(
+ text: string,
+ size: number = 200,
+ color: QRCodeColor = 'black',
+ format: QRCodeFormat = 'png'
+): string {
+ const encodedText = encodeURIComponent(text);
+ const colorConfig = QR_COLORS[color];
+ return `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodedText}&color=${colorConfig.color}&bgcolor=${colorConfig.bg}&format=${format}`;
+}
+
+export function generateQRCodeSVG(
+ text: string,
+ size: number = 200,
+ color: QRCodeColor = 'black'
+): string {
+ return generateQRCodeURL(text, size, color, 'svg');
+}
+
+export function generateQRCodeDataURL(
+ text: string,
+ size: number = 200,
+ color: QRCodeColor = 'black'
+): string {
+ return generateQRCodeURL(text, size, color, 'png');
+}
+
+export function createQRCodeElement(
+ text: string,
+ size: number = 200,
+ color: QRCodeColor = 'black'
+): HTMLImageElement {
+ const img = new Image();
+ img.src = generateQRCodeURL(text, size, color, 'png');
+ img.width = size;
+ img.height = size;
+ img.alt = 'QR Code';
+ return img;
+}
+
+export async function downloadQRCode(
+ text: string,
+ filename: string = 'qrcode',
+ size: number = 400,
+ color: QRCodeColor = 'black',
+ format: QRCodeFormat = 'png',
+ rotation: QRCodeRotation = 0
+) {
+ const url = generateQRCodeURL(text, size, color, format);
+ const fullFilename = `${filename}.${format}`;
+
+ if (format === 'svg') {
+ // Handle SVG with or without rotation
+ fetch(url)
+ .then((response) => response.text())
+ .then((svgText) => {
+ let finalSvg = svgText;
+
+ if (rotation !== 0) {
+ // Apply rotation transform to SVG
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(svgText, 'image/svg+xml');
+ const svgElement = doc.documentElement;
+
+ // Get original dimensions
+ const width = parseInt(svgElement.getAttribute('width') || `${size}`);
+ const height = parseInt(svgElement.getAttribute('height') || `${size}`);
+
+ // Calculate new dimensions for rotated SVG
+ const radians = (rotation * Math.PI) / 180;
+ const sin = Math.abs(Math.sin(radians));
+ const cos = Math.abs(Math.cos(radians));
+ const newWidth = Math.round(width * cos + height * sin);
+ const newHeight = Math.round(width * sin + height * cos);
+
+ // Update SVG dimensions
+ svgElement.setAttribute('width', `${newWidth}`);
+ svgElement.setAttribute('height', `${newHeight}`);
+
+ // Add a group with rotation transform
+ const g = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
+ g.setAttribute(
+ 'transform',
+ `translate(${newWidth / 2},${newHeight / 2}) rotate(${rotation}) translate(${-width / 2},${-height / 2})`
+ );
+
+ // Move all existing children into the group
+ while (svgElement.firstChild) {
+ g.appendChild(svgElement.firstChild);
+ }
+ svgElement.appendChild(g);
+
+ // Serialize back to string
+ const serializer = new XMLSerializer();
+ finalSvg = serializer.serializeToString(doc);
+ }
+
+ const blob = new Blob([finalSvg], { type: 'image/svg+xml' });
+ const objectUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = objectUrl;
+ a.download = fullFilename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(objectUrl);
+ });
+ } else if (rotation === 0) {
+ // No rotation needed for PNG/JPG
+ fetch(url)
+ .then((response) => response.blob())
+ .then((blob) => {
+ const a = document.createElement('a');
+ const objectUrl = URL.createObjectURL(blob);
+ a.href = objectUrl;
+ a.download = fullFilename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(objectUrl);
+ });
+ } else {
+ // Apply rotation using canvas for PNG/JPG
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.src = url;
+
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ // Calculate new dimensions for rotated image
+ const radians = (rotation * Math.PI) / 180;
+ const sin = Math.abs(Math.sin(radians));
+ const cos = Math.abs(Math.cos(radians));
+
+ const newWidth = Math.round(img.width * cos + img.height * sin);
+ const newHeight = Math.round(img.width * sin + img.height * cos);
+
+ canvas.width = newWidth;
+ canvas.height = newHeight;
+
+ // Apply rotation
+ ctx.translate(newWidth / 2, newHeight / 2);
+ ctx.rotate(radians);
+ ctx.drawImage(img, -img.width / 2, -img.height / 2);
+
+ // Convert and download
+ const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
+ canvas.toBlob((blob) => {
+ if (blob) {
+ const a = document.createElement('a');
+ const objectUrl = URL.createObjectURL(blob);
+ a.href = objectUrl;
+ a.download = fullFilename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(objectUrl);
+ }
+ }, mimeType);
+ };
+ }
+}
diff --git a/apps/uload/apps/web/src/lib/schemas/cardSchemas.ts b/apps/uload/apps/web/src/lib/schemas/cardSchemas.ts
new file mode 100644
index 000000000..96aff9056
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/schemas/cardSchemas.ts
@@ -0,0 +1,256 @@
+import { z } from 'zod';
+
+// Base schemas
+export const RenderModeSchema = z.enum(['beginner', 'advanced', 'expert']);
+
+export const CardVariantSchema = z.enum([
+ 'default',
+ 'compact',
+ 'hero',
+ 'minimal',
+ 'glass',
+ 'gradient',
+]);
+
+// Metadata schema
+export const CardMetadataSchema = z.object({
+ name: z.string().max(100).optional(),
+ description: z.string().max(500).optional(),
+ author: z.string().optional(),
+ version: z.string().optional(),
+ created: z.string().datetime().optional(),
+ updated: z.string().datetime().optional(),
+ tags: z.array(z.string()).max(10).optional(),
+ page: z.string().optional(),
+ position: z.number().nonnegative().optional(),
+ isActive: z.boolean().optional(),
+ isPublic: z.boolean().optional(),
+});
+
+// Constraints schema
+export const CardConstraintsSchema = z.object({
+ aspectRatio: z
+ .string()
+ .regex(/^(\d+\/\d+|auto)$/)
+ .optional(),
+ maxWidth: z.string().optional(),
+ minHeight: z.string().optional(),
+ maxHeight: z.string().optional(),
+ maxModules: z.number().min(1).max(50).optional(),
+ maxHTMLSize: z.number().min(1000).max(200000).optional(),
+ maxCSSSize: z.number().min(1000).max(100000).optional(),
+});
+
+// Theme schema
+export const ThemeSchema = z.object({
+ id: z.string().optional(),
+ name: z.string().optional(),
+ colors: z.record(z.string(), z.string()).optional(),
+ typography: z
+ .object({
+ fontFamily: z.string().optional(),
+ fontSize: z.record(z.string(), z.string()).optional(),
+ fontWeight: z.record(z.string(), z.number()).optional(),
+ lineHeight: z.record(z.string(), z.string()).optional(),
+ })
+ .optional(),
+ spacing: z.record(z.string(), z.string()).optional(),
+ borderRadius: z.record(z.string(), z.string()).optional(),
+ shadows: z.record(z.string(), z.string()).optional(),
+});
+
+// Module schema
+export const ModuleSchema = z.object({
+ id: z.string(),
+ type: z.enum(['header', 'content', 'footer', 'media', 'stats', 'actions', 'links', 'custom']),
+ props: z.record(z.string(), z.any()),
+ order: z.number(),
+ visibility: z.enum(['always', 'desktop', 'mobile']).optional(),
+ grid: z
+ .object({
+ col: z.number().optional(),
+ row: z.number().optional(),
+ colSpan: z.number().optional(),
+ rowSpan: z.number().optional(),
+ })
+ .optional(),
+ className: z.string().optional(),
+});
+
+// Template variable schema
+export const TemplateVariableSchema = z.object({
+ name: z.string(),
+ type: z.enum(['text', 'number', 'image', 'link', 'list', 'boolean', 'color']),
+ label: z.string(),
+ default: z.any().optional(),
+ required: z.boolean().optional(),
+ placeholder: z.string().optional(),
+ options: z
+ .array(
+ z.object({
+ label: z.string(),
+ value: z.any(),
+ })
+ )
+ .optional(),
+});
+
+// Card configuration schemas (discriminated union)
+export const BeginnerConfigSchema = z.object({
+ mode: z.literal('beginner'),
+ modules: z.array(ModuleSchema).min(1).max(20),
+ theme: ThemeSchema.optional(),
+ layout: z
+ .object({
+ columns: z.number().min(1).max(4).optional(),
+ gap: z.string().optional(),
+ padding: z.string().optional(),
+ })
+ .optional(),
+ animations: z
+ .object({
+ hover: z.boolean().optional(),
+ entrance: z.enum(['fade', 'slide', 'scale', 'none']).optional(),
+ })
+ .optional(),
+});
+
+export const AdvancedConfigSchema = z.object({
+ mode: z.literal('advanced'),
+ template: z.string().min(1).max(100000),
+ css: z.string().max(50000).optional(),
+ variables: z.array(TemplateVariableSchema),
+ values: z.record(z.string(), z.any()),
+});
+
+export const ExpertConfigSchema = z.object({
+ mode: z.literal('expert'),
+ html: z.string().min(1).max(100000),
+ css: z.string().min(1).max(50000),
+ javascript: z.string().optional(), // Note: Will be rejected in validation
+});
+
+export const CardConfigSchema = z.discriminatedUnion('mode', [
+ BeginnerConfigSchema,
+ AdvancedConfigSchema,
+ ExpertConfigSchema,
+]);
+
+// Main Card schema
+export const CardSchema = z.object({
+ id: z.string(),
+ config: CardConfigSchema,
+ metadata: CardMetadataSchema,
+ constraints: CardConstraintsSchema,
+ variant: CardVariantSchema.optional(),
+});
+
+// Database Card schema
+export const DBCardSchema = z.object({
+ id: z.string(),
+ user_id: z.string(),
+ config: z.string(), // JSON string
+ metadata: z.string(), // JSON string
+ constraints: z.string(), // JSON string
+ variant: z.string().optional(),
+ created: z.string().datetime(),
+ updated: z.string().datetime(),
+});
+
+// Module Props schemas
+export const HeaderModulePropsSchema = z.object({
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ avatar: z.string().url().optional(),
+ badge: z.string().optional(),
+ icon: z.string().optional(),
+});
+
+export const ContentModulePropsSchema = z.object({
+ text: z.string().optional(),
+ html: z.string().optional(),
+ truncate: z.boolean().optional(),
+ maxLines: z.number().optional(),
+});
+
+export const LinksModulePropsSchema = z.object({
+ links: z.array(
+ z.object({
+ label: z.string(),
+ href: z.string(),
+ icon: z.string().optional(),
+ description: z.string().optional(),
+ })
+ ),
+ style: z.enum(['button', 'list', 'card']).optional(),
+ columns: z.literal(1).or(z.literal(2)).optional(),
+ target: z.enum(['_blank', '_self']).optional(),
+});
+
+export const MediaModulePropsSchema = z.object({
+ type: z.enum(['image', 'video', 'qr']),
+ src: z.string().optional(),
+ alt: z.string().optional(),
+ aspectRatio: z.string().optional(),
+ qrData: z.string().optional(),
+});
+
+export const StatsModulePropsSchema = z.object({
+ stats: z.array(
+ z.object({
+ label: z.string(),
+ value: z.union([z.string(), z.number()]),
+ change: z.number().optional(),
+ icon: z.string().optional(),
+ })
+ ),
+ layout: z.enum(['grid', 'list']).optional(),
+});
+
+export const ActionsModulePropsSchema = z.object({
+ actions: z.array(
+ z.object({
+ label: z.string(),
+ href: z.string().optional(),
+ variant: z.enum(['primary', 'secondary', 'ghost']).optional(),
+ icon: z.string().optional(),
+ })
+ ),
+ layout: z.enum(['horizontal', 'vertical']).optional(),
+});
+
+export const FooterModulePropsSchema = z.object({
+ text: z.string().optional(),
+ links: z
+ .array(
+ z.object({
+ label: z.string(),
+ href: z.string(),
+ })
+ )
+ .optional(),
+ copyright: z.string().optional(),
+});
+
+// Validation helpers
+export function validateCard(data: unknown) {
+ return CardSchema.safeParse(data);
+}
+
+export function validateCardConfig(data: unknown) {
+ return CardConfigSchema.safeParse(data);
+}
+
+export function validateModule(data: unknown) {
+ return ModuleSchema.safeParse(data);
+}
+
+// Type exports
+export type Card = z.infer;
+export type CardConfig = z.infer;
+export type CardMetadata = z.infer;
+export type CardConstraints = z.infer;
+export type Module = z.infer;
+export type Theme = z.infer;
+export type RenderMode = z.infer;
+export type CardVariant = z.infer;
diff --git a/apps/uload/apps/web/src/lib/scripts/update-links-collection.js b/apps/uload/apps/web/src/lib/scripts/update-links-collection.js
new file mode 100644
index 000000000..e70a76b45
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/scripts/update-links-collection.js
@@ -0,0 +1,87 @@
+// Script to add account_owner field to links collection
+// Run this with: node src/lib/scripts/update-links-collection.js
+
+import PocketBase from 'pocketbase';
+
+// Use environment variable or fallback to production
+const POCKETBASE_URL = process.env.PUBLIC_POCKETBASE_URL || 'https://pb.ulo.ad';
+const pb = new PocketBase(POCKETBASE_URL);
+
+console.log(`Connecting to PocketBase at: ${POCKETBASE_URL}`);
+
+// You'll need to authenticate as admin first
+// This is just a placeholder - do not commit real credentials
+const ADMIN_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL;
+const ADMIN_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD;
+
+async function updateLinksCollection() {
+ try {
+ // Authenticate as admin
+ await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log('✅ Authenticated as admin');
+
+ // Get the current links collection
+ const collection = await pb.collections.getOne('links');
+ console.log('✅ Retrieved links collection');
+
+ // Add account_owner field to the existing fields
+ const updatedFields = [...collection.fields];
+
+ // Check if account_owner already exists
+ const hasAccountOwner = updatedFields.some((f) => f.name === 'account_owner');
+ if (!hasAccountOwner) {
+ // Insert account_owner field after user_id
+ const userIdIndex = updatedFields.findIndex((f) => f.name === 'user_id');
+ updatedFields.splice(userIdIndex + 1, 0, {
+ name: 'account_owner',
+ type: 'relation',
+ required: false,
+ collectionId: '_pb_users_auth_',
+ cascadeDelete: false,
+ maxSelect: 1,
+ minSelect: 0,
+ });
+ }
+
+ // Update the collection
+ await pb.collections.update('links', {
+ ...collection,
+ fields: updatedFields,
+ // Update rules to include account_owner checks
+ listRule:
+ 'user_id = @request.auth.id || created_by = @request.auth.id || account_owner = @request.auth.id || is_active = true',
+ viewRule:
+ 'user_id = @request.auth.id || created_by = @request.auth.id || account_owner = @request.auth.id || is_active = true',
+ updateRule:
+ 'created_by = @request.auth.id || (account_owner = @request.auth.id && created_by = @request.auth.id)',
+ deleteRule:
+ 'created_by = @request.auth.id || (account_owner = @request.auth.id && created_by = @request.auth.id)',
+ });
+
+ console.log('✅ Successfully updated links collection with account_owner field');
+
+ // Migrate existing data: set account_owner = user_id for all existing links
+ console.log('🔄 Migrating existing links...');
+
+ const allLinks = await pb.collection('links').getFullList();
+ let migrated = 0;
+
+ for (const link of allLinks) {
+ if (!link.account_owner && link.user_id) {
+ await pb.collection('links').update(link.id, {
+ account_owner: link.user_id,
+ created_by: link.created_by || link.user_id,
+ });
+ migrated++;
+ }
+ }
+
+ console.log(`✅ Migrated ${migrated} existing links`);
+ } catch (error) {
+ console.error('❌ Error:', error);
+ process.exit(1);
+ }
+}
+
+// Run the update
+updateLinksCollection();
diff --git a/apps/uload/apps/web/src/lib/security/totp.ts b/apps/uload/apps/web/src/lib/security/totp.ts
new file mode 100644
index 000000000..bebf8a140
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/security/totp.ts
@@ -0,0 +1,284 @@
+// TOTP (Time-based One-Time Password) Implementation für 2FA
+// Verwendet RFC 6238 Standard
+
+import { createHmac } from 'crypto';
+
+// TOTP Configuration
+export interface TOTPConfig {
+ secret: string;
+ window?: number; // Zeitfenster in 30-Sekunden-Schritten (default: 1)
+ digits?: number; // Anzahl Ziffern (default: 6)
+ period?: number; // Zeitperiode in Sekunden (default: 30)
+ algorithm?: 'sha1' | 'sha256' | 'sha512'; // Hash-Algorithmus (default: sha1)
+}
+
+export interface TOTPResult {
+ token: string;
+ timeRemaining: number;
+ window: number;
+}
+
+// Base32 Encoding/Decoding für Secrets
+const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+const BASE32_MAP: { [key: string]: number } = {};
+for (let i = 0; i < BASE32_CHARS.length; i++) {
+ BASE32_MAP[BASE32_CHARS[i]] = i;
+}
+
+function base32Decode(encoded: string): Buffer {
+ encoded = encoded.replace(/=+$/, '').toUpperCase();
+ let bits = 0;
+ let value = 0;
+ let output = Buffer.alloc(Math.ceil((encoded.length * 5) / 8));
+ let index = 0;
+
+ for (const char of encoded) {
+ value = (value << 5) | BASE32_MAP[char];
+ bits += 5;
+
+ if (bits >= 8) {
+ output[index++] = (value >>> (bits - 8)) & 255;
+ bits -= 8;
+ }
+ }
+
+ return output.slice(0, index);
+}
+
+function base32Encode(buffer: Buffer): string {
+ let encoded = '';
+ let bits = 0;
+ let value = 0;
+
+ for (const byte of buffer) {
+ value = (value << 8) | byte;
+ bits += 8;
+
+ while (bits >= 5) {
+ encoded += BASE32_CHARS[(value >>> (bits - 5)) & 31];
+ bits -= 5;
+ }
+ }
+
+ if (bits > 0) {
+ encoded += BASE32_CHARS[(value << (5 - bits)) & 31];
+ }
+
+ // Padding hinzufügen
+ while (encoded.length % 8 !== 0) {
+ encoded += '=';
+ }
+
+ return encoded;
+}
+
+// Secret generieren
+export function generateSecret(length: number = 32): string {
+ const buffer = Buffer.alloc(length);
+
+ // Sichere Zufallsbytes generieren
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
+ // Browser
+ const array = new Uint8Array(length);
+ crypto.getRandomValues(array);
+ return base32Encode(Buffer.from(array));
+ } else {
+ // Node.js
+ const { randomBytes } = require('crypto');
+ return base32Encode(randomBytes(length));
+ }
+}
+
+// Aktuellen Zeitslot berechnen
+function getCurrentTimeSlot(period: number = 30): number {
+ return Math.floor(Date.now() / 1000 / period);
+}
+
+// HMAC-basierte OTP generieren
+function generateHOTP(
+ secret: string,
+ counter: number,
+ digits: number = 6,
+ algorithm: string = 'sha1'
+): string {
+ const key = base32Decode(secret);
+
+ // Counter als 8-Byte Big-Endian Buffer
+ const counterBuffer = Buffer.alloc(8);
+ counterBuffer.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
+ counterBuffer.writeUInt32BE(counter & 0xffffffff, 4);
+
+ // HMAC berechnen
+ const hmac = createHmac(algorithm, key);
+ hmac.update(counterBuffer);
+ const hash = hmac.digest();
+
+ // Dynamic truncation (RFC 4226)
+ const offset = hash[hash.length - 1] & 0x0f;
+ const code =
+ ((hash[offset] & 0x7f) << 24) |
+ ((hash[offset + 1] & 0xff) << 16) |
+ ((hash[offset + 2] & 0xff) << 8) |
+ (hash[offset + 3] & 0xff);
+
+ // Auf gewünschte Anzahl Ziffern reduzieren
+ const otp = (code % Math.pow(10, digits)).toString();
+ return otp.padStart(digits, '0');
+}
+
+// TOTP Token generieren
+export function generateTOTP(config: TOTPConfig): TOTPResult {
+ const { secret, digits = 6, period = 30, algorithm = 'sha1' } = config;
+
+ const timeSlot = getCurrentTimeSlot(period);
+ const token = generateHOTP(secret, timeSlot, digits, algorithm);
+
+ // Verbleibende Zeit bis zum nächsten Token
+ const timeRemaining = period - (Math.floor(Date.now() / 1000) % period);
+
+ return {
+ token,
+ timeRemaining,
+ window: timeSlot,
+ };
+}
+
+// TOTP Token verifizieren
+export function verifyTOTP(token: string, config: TOTPConfig): boolean {
+ const { secret, window = 1, digits = 6, period = 30, algorithm = 'sha1' } = config;
+
+ const currentTimeSlot = getCurrentTimeSlot(period);
+
+ // Prüfe aktuelles und benachbarte Zeitfenster
+ for (let i = -window; i <= window; i++) {
+ const timeSlot = currentTimeSlot + i;
+ const expectedToken = generateHOTP(secret, timeSlot, digits, algorithm);
+
+ if (constantTimeEquals(token, expectedToken)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+// Constant-time string comparison (verhindert Timing-Angriffe)
+function constantTimeEquals(a: string, b: string): boolean {
+ if (a.length !== b.length) {
+ return false;
+ }
+
+ let result = 0;
+ for (let i = 0; i < a.length; i++) {
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ }
+
+ return result === 0;
+}
+
+// QR Code URL für Authenticator Apps generieren
+export function generateQRCodeURL(
+ secret: string,
+ accountName: string,
+ issuer: string = 'uLoad',
+ algorithm: string = 'SHA1',
+ digits: number = 6,
+ period: number = 30
+): string {
+ const params = new URLSearchParams({
+ secret,
+ issuer,
+ algorithm,
+ digits: digits.toString(),
+ period: period.toString(),
+ });
+
+ return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?${params}`;
+}
+
+// Backup Codes generieren
+export function generateBackupCodes(count: number = 10): string[] {
+ const codes: string[] = [];
+
+ for (let i = 0; i < count; i++) {
+ // 8-stellige Backup-Codes generieren
+ let code = '';
+ for (let j = 0; j < 8; j++) {
+ code += Math.floor(Math.random() * 10).toString();
+ }
+ // Formatierung: XXXX-XXXX
+ codes.push(`${code.slice(0, 4)}-${code.slice(4, 8)}`);
+ }
+
+ return codes;
+}
+
+// Backup Code validieren und als verbraucht markieren
+export function validateBackupCode(
+ code: string,
+ availableCodes: string[]
+): { isValid: boolean; remainingCodes: string[] } {
+ const normalizedCode = code.replace(/[-\s]/g, '');
+ const codeIndex = availableCodes.findIndex(
+ (availableCode) => availableCode.replace(/[-\s]/g, '') === normalizedCode
+ );
+
+ if (codeIndex === -1) {
+ return { isValid: false, remainingCodes: availableCodes };
+ }
+
+ // Code entfernen (als verbraucht markieren)
+ const remainingCodes = [...availableCodes];
+ remainingCodes.splice(codeIndex, 1);
+
+ return { isValid: true, remainingCodes };
+}
+
+// Hilfsfunktionen für UI
+export function formatTOTPToken(token: string): string {
+ // Format: XXX XXX
+ if (token.length === 6) {
+ return `${token.slice(0, 3)} ${token.slice(3, 6)}`;
+ }
+ return token;
+}
+
+export function formatBackupCode(code: string): string {
+ // Format: XXXX-XXXX
+ const cleanCode = code.replace(/[-\s]/g, '');
+ if (cleanCode.length === 8) {
+ return `${cleanCode.slice(0, 4)}-${cleanCode.slice(4, 8)}`;
+ }
+ return code;
+}
+
+// Secret für sichere Speicherung verschlüsseln (vereinfacht)
+export function encryptSecret(secret: string, password: string): string {
+ // In Produktion sollte eine robuste Verschlüsselung verwendet werden
+ // Dies ist nur ein Beispiel - verwende crypto.subtle.encrypt() oder ähnliches
+ const encoder = new TextEncoder();
+ const data = encoder.encode(secret);
+ const key = encoder.encode(password.padEnd(32, '0').slice(0, 32));
+
+ // XOR-basierte "Verschlüsselung" (NUR FÜR DEMO!)
+ const encrypted = new Uint8Array(data.length);
+ for (let i = 0; i < data.length; i++) {
+ encrypted[i] = data[i] ^ key[i % key.length];
+ }
+
+ return Buffer.from(encrypted).toString('base64');
+}
+
+export function decryptSecret(encryptedSecret: string, password: string): string {
+ // Entsprechende Entschlüsselung (NUR FÜR DEMO!)
+ const encoder = new TextEncoder();
+ const data = Buffer.from(encryptedSecret, 'base64');
+ const key = encoder.encode(password.padEnd(32, '0').slice(0, 32));
+
+ const decrypted = new Uint8Array(data.length);
+ for (let i = 0; i < data.length; i++) {
+ decrypted[i] = data[i] ^ key[i % key.length];
+ }
+
+ return new TextDecoder().decode(decrypted);
+}
diff --git a/apps/uload/apps/web/src/lib/services/cardConverter.ts b/apps/uload/apps/web/src/lib/services/cardConverter.ts
new file mode 100644
index 000000000..5681d7d4d
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/services/cardConverter.ts
@@ -0,0 +1,494 @@
+import type { CardConfig, Module, TemplateVariable } from '$lib/components/cards/types';
+import { cardSanitizer } from './cardSanitizer';
+
+class CardConverter {
+ /**
+ * Convert any card config to modular (beginner) format
+ */
+ async toModular(config: CardConfig): Promise> {
+ if (config.mode === 'beginner') {
+ return config;
+ }
+
+ if (config.mode === 'advanced') {
+ return this.templateToModular(config);
+ }
+
+ if (config.mode === 'expert') {
+ return this.customToModular(config);
+ }
+
+ throw new Error(`Unknown card mode: ${(config as any).mode}`);
+ }
+
+ /**
+ * Convert any card config to template (advanced) format
+ */
+ async toTemplate(config: CardConfig): Promise> {
+ if (config.mode === 'advanced') {
+ return config;
+ }
+
+ if (config.mode === 'beginner') {
+ return this.modularToTemplate(config);
+ }
+
+ if (config.mode === 'expert') {
+ return this.customToTemplate(config);
+ }
+
+ throw new Error(`Unknown card mode: ${(config as any).mode}`);
+ }
+
+ /**
+ * Convert any card config to custom (expert) format
+ */
+ async toCustom(config: CardConfig): Promise> {
+ if (config.mode === 'expert') {
+ return config;
+ }
+
+ if (config.mode === 'beginner') {
+ return this.modularToCustom(config);
+ }
+
+ if (config.mode === 'advanced') {
+ return this.templateToCustom(config);
+ }
+
+ throw new Error(`Unknown card mode: ${(config as any).mode}`);
+ }
+
+ /**
+ * Convert template to modular format
+ */
+ private async templateToModular(
+ config: Extract
+ ): Promise> {
+ const modules: Module[] = [];
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(config.template, 'text/html');
+
+ // Analyze HTML structure and extract modules
+ let order = 0;
+
+ // Check for headers
+ const headers = doc.querySelectorAll('h1, h2, h3');
+ if (headers.length > 0) {
+ const header = headers[0];
+ const subtitle =
+ header.nextElementSibling?.tagName === 'P' ? header.nextElementSibling.textContent : '';
+
+ modules.push({
+ id: `header_${order++}`,
+ type: 'header',
+ props: {
+ title: header.textContent || '',
+ subtitle: subtitle || '',
+ },
+ order,
+ });
+ }
+
+ // Check for images
+ const images = doc.querySelectorAll('img');
+ images.forEach((img) => {
+ modules.push({
+ id: `media_${order++}`,
+ type: 'media',
+ props: {
+ type: 'image',
+ src: img.getAttribute('src') || '',
+ alt: img.getAttribute('alt') || '',
+ },
+ order,
+ });
+ });
+
+ // Check for links
+ const links = doc.querySelectorAll('a');
+ if (links.length > 0) {
+ const linkItems = Array.from(links).map((link) => ({
+ label: link.textContent || '',
+ href: link.getAttribute('href') || '#',
+ icon: '',
+ }));
+
+ modules.push({
+ id: `links_${order++}`,
+ type: 'links',
+ props: {
+ links: linkItems,
+ style: 'button',
+ },
+ order,
+ });
+ }
+
+ // Check for remaining content
+ const paragraphs = doc.querySelectorAll('p, div');
+ if (paragraphs.length > 0) {
+ const content = Array.from(paragraphs)
+ .map((p) => p.textContent)
+ .filter((text) => text && text.trim())
+ .join('\n\n');
+
+ if (content) {
+ modules.push({
+ id: `content_${order++}`,
+ type: 'content',
+ props: {
+ text: content,
+ },
+ order,
+ });
+ }
+ }
+
+ return {
+ mode: 'beginner',
+ modules,
+ theme: this.extractThemeFromCSS(config.css),
+ layout: {
+ columns: 1,
+ gap: '1rem',
+ padding: '1.5rem',
+ },
+ };
+ }
+
+ /**
+ * Convert custom HTML to modular format
+ */
+ private async customToModular(
+ config: Extract
+ ): Promise> {
+ // Similar to templateToModular but without variables
+ const templateConfig: Extract = {
+ mode: 'advanced',
+ template: config.html,
+ css: config.css,
+ variables: [],
+ values: {},
+ };
+
+ return this.templateToModular(templateConfig);
+ }
+
+ /**
+ * Convert modular to template format
+ */
+ private modularToTemplate(
+ config: Extract
+ ): Extract {
+ let template = '\n';
+ const variables: TemplateVariable[] = [];
+ const values: Record
= {};
+
+ // Convert each module to template HTML
+ config.modules.forEach((module) => {
+ switch (module.type) {
+ case 'header':
+ if (module.props.title) {
+ template += ` {{title}} \n`;
+ variables.push({
+ name: 'title',
+ type: 'text',
+ label: 'Title',
+ default: module.props.title,
+ });
+ values.title = module.props.title;
+ }
+ if (module.props.subtitle) {
+ template += ` {{subtitle}}
\n`;
+ variables.push({
+ name: 'subtitle',
+ type: 'text',
+ label: 'Subtitle',
+ default: module.props.subtitle,
+ });
+ values.subtitle = module.props.subtitle;
+ }
+ break;
+
+ case 'content':
+ template += ` {{content}}
\n`;
+ variables.push({
+ name: 'content',
+ type: 'text',
+ label: 'Content',
+ default: module.props.text || module.props.html,
+ });
+ values.content = module.props.text || module.props.html;
+ break;
+
+ case 'media':
+ if (module.props.type === 'image') {
+ template += ` \n`;
+ variables.push(
+ {
+ name: 'image_url',
+ type: 'image',
+ label: 'Image URL',
+ default: module.props.src,
+ },
+ {
+ name: 'image_alt',
+ type: 'text',
+ label: 'Image Alt Text',
+ default: module.props.alt,
+ }
+ );
+ values.image_url = module.props.src;
+ values.image_alt = module.props.alt;
+ }
+ break;
+
+ case 'links':
+ template += ` \n`;
+ module.props.links?.forEach((link: any, index: number) => {
+ template += `
{{link${index}_text}} \n`;
+ variables.push(
+ {
+ name: `link${index}_url`,
+ type: 'link',
+ label: `Link ${index + 1} URL`,
+ default: link.href,
+ },
+ {
+ name: `link${index}_text`,
+ type: 'text',
+ label: `Link ${index + 1} Text`,
+ default: link.label,
+ }
+ );
+ values[`link${index}_url`] = link.href;
+ values[`link${index}_text`] = link.label;
+ });
+ template += `
\n`;
+ break;
+
+ case 'stats':
+ template += ` \n`;
+ module.props.stats?.forEach((stat: any, index: number) => {
+ template += `
\n`;
+ template += ` {{stat${index}_value}} \n`;
+ template += ` {{stat${index}_label}} \n`;
+ template += `
\n`;
+ variables.push(
+ {
+ name: `stat${index}_value`,
+ type: 'number',
+ label: `Stat ${index + 1} Value`,
+ default: stat.value,
+ },
+ {
+ name: `stat${index}_label`,
+ type: 'text',
+ label: `Stat ${index + 1} Label`,
+ default: stat.label,
+ }
+ );
+ values[`stat${index}_value`] = stat.value;
+ values[`stat${index}_label`] = stat.label;
+ });
+ template += `
\n`;
+ break;
+ }
+ });
+
+ template += ' ';
+
+ // Generate CSS from theme
+ const css = this.generateCSSFromTheme(config.theme);
+
+ return {
+ mode: 'advanced',
+ template,
+ css,
+ variables,
+ values,
+ };
+ }
+
+ /**
+ * Convert custom HTML to template format
+ */
+ private async customToTemplate(
+ config: Extract
+ ): Promise> {
+ // Extract variables from HTML
+ const variables = cardSanitizer.extractVariables(config.html);
+ const values: Record = {};
+
+ // Set default values
+ variables.forEach((variable) => {
+ values[variable.name] = variable.default || '';
+ });
+
+ return {
+ mode: 'advanced',
+ template: config.html,
+ css: config.css,
+ variables,
+ values,
+ };
+ }
+
+ /**
+ * Convert modular to custom HTML format
+ */
+ private modularToCustom(
+ config: Extract
+ ): Extract {
+ // First convert to template
+ const templateConfig = this.modularToTemplate(config);
+
+ // Then replace variables with actual values
+ let html = cardSanitizer.replaceVariables(templateConfig.template, templateConfig.values);
+
+ return {
+ mode: 'expert',
+ html,
+ css: templateConfig.css || '',
+ };
+ }
+
+ /**
+ * Convert template to custom HTML format
+ */
+ private templateToCustom(
+ config: Extract
+ ): Extract {
+ // Replace variables with actual values
+ const html = cardSanitizer.replaceVariables(config.template, config.values);
+
+ return {
+ mode: 'expert',
+ html,
+ css: config.css || '',
+ };
+ }
+
+ /**
+ * Extract theme from CSS
+ */
+ private extractThemeFromCSS(css?: string): any {
+ if (!css) return undefined;
+
+ const theme: any = {
+ colors: {},
+ };
+
+ // Extract color variables
+ const colorRegex = /--([^:]+):\s*([^;]+);/g;
+ let match;
+ while ((match = colorRegex.exec(css)) !== null) {
+ const varName = match[1].trim();
+ const value = match[2].trim();
+ if (varName.includes('color') || varName.includes('bg')) {
+ theme.colors[varName] = value;
+ }
+ }
+
+ return Object.keys(theme.colors).length > 0 ? theme : undefined;
+ }
+
+ /**
+ * Generate CSS from theme
+ */
+ private generateCSSFromTheme(theme?: any): string {
+ let css = `
+.card-content {
+ padding: 1.5rem;
+ height: 100%;
+}
+
+h2 {
+ margin-bottom: 0.5rem;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-primary, #1f2937);
+}
+
+.subtitle {
+ color: var(--text-muted, #6b7280);
+ margin-bottom: 1rem;
+}
+
+.content {
+ margin: 1rem 0;
+ line-height: 1.6;
+ color: var(--text-primary, #1f2937);
+}
+
+.links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.link-button {
+ padding: 0.5rem 1rem;
+ background: var(--primary, #3b82f6);
+ color: white;
+ text-decoration: none;
+ border-radius: 0.375rem;
+ transition: background 0.2s;
+}
+
+.link-button:hover {
+ background: var(--primary-dark, #2563eb);
+}
+
+.media-image {
+ width: 100%;
+ height: auto;
+ border-radius: 0.5rem;
+ margin: 1rem 0;
+}
+
+.stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 1rem;
+ margin: 1rem 0;
+}
+
+.stat-item {
+ text-align: center;
+ padding: 1rem;
+ background: var(--bg-secondary, #f9fafb);
+ border-radius: 0.5rem;
+}
+
+.stat-value {
+ display: block;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--primary, #3b82f6);
+}
+
+.stat-label {
+ display: block;
+ font-size: 0.875rem;
+ color: var(--text-muted, #6b7280);
+ margin-top: 0.25rem;
+}`;
+
+ // Add theme variables if available
+ if (theme?.colors) {
+ const vars = Object.entries(theme.colors)
+ .map(([key, value]) => ` --${key}: ${value};`)
+ .join('\n');
+
+ css = `:root {\n${vars}\n}\n\n${css}`;
+ }
+
+ return css;
+ }
+}
+
+// Export singleton instance
+export const cardConverter = new CardConverter();
diff --git a/apps/uload/apps/web/src/lib/services/cardSanitizer.ts b/apps/uload/apps/web/src/lib/services/cardSanitizer.ts
new file mode 100644
index 000000000..076e404ab
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/services/cardSanitizer.ts
@@ -0,0 +1,400 @@
+import DOMPurify from 'isomorphic-dompurify';
+import type { CardConstraints, TemplateVariable } from '$lib/components/cards/types';
+
+export interface SanitizationOptions {
+ allowedTags?: string[];
+ allowedAttributes?: Record;
+ allowedStyles?: string[];
+ maxNesting?: number;
+ removeScripts?: boolean;
+ removeEventHandlers?: boolean;
+ removeImports?: boolean;
+}
+
+export class CardSanitizer {
+ private domPurify = DOMPurify;
+
+ /**
+ * Sanitize HTML content for safe rendering
+ */
+ sanitizeHTML(html: string, options?: SanitizationOptions): string {
+ const defaultOptions: SanitizationOptions = {
+ allowedTags: [
+ 'div',
+ 'span',
+ 'p',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'a',
+ 'img',
+ 'ul',
+ 'ol',
+ 'li',
+ 'strong',
+ 'em',
+ 'b',
+ 'i',
+ 'br',
+ 'hr',
+ 'blockquote',
+ 'pre',
+ 'code',
+ 'table',
+ 'thead',
+ 'tbody',
+ 'tr',
+ 'td',
+ 'th',
+ 'section',
+ 'article',
+ 'nav',
+ 'header',
+ 'footer',
+ 'aside',
+ 'main',
+ 'figure',
+ 'figcaption',
+ 'button',
+ 'svg',
+ 'path',
+ 'circle',
+ 'rect',
+ 'line',
+ 'polygon',
+ ],
+ allowedAttributes: {
+ '*': ['class', 'id', 'style'],
+ a: ['href', 'target', 'rel'],
+ img: ['src', 'alt', 'width', 'height'],
+ svg: ['viewBox', 'width', 'height', 'fill', 'stroke'],
+ path: ['d', 'fill', 'stroke', 'stroke-width'],
+ button: ['type', 'disabled'],
+ },
+ removeScripts: true,
+ removeEventHandlers: true,
+ };
+
+ const mergedOptions = { ...defaultOptions, ...options };
+
+ // Configure DOMPurify
+ const config: any = {
+ ALLOWED_TAGS: mergedOptions.allowedTags,
+ ALLOWED_ATTR: [],
+ FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'textarea', 'select'],
+ FORBID_ATTR: ['onclick', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur'],
+ };
+
+ // Build allowed attributes list
+ if (mergedOptions.allowedAttributes) {
+ for (const [tag, attrs] of Object.entries(mergedOptions.allowedAttributes)) {
+ if (tag === '*') {
+ config.ALLOWED_ATTR.push(...attrs);
+ } else {
+ attrs.forEach((attr) => {
+ config.ALLOWED_ATTR.push(`${tag}:${attr}`);
+ });
+ }
+ }
+ }
+
+ // Sanitize HTML
+ const sanitized = this.domPurify.sanitize(html, config);
+
+ // Convert to string and do additional cleaning for javascript: URLs
+ const result = String(sanitized);
+ return result.replace(/javascript:/gi, '').replace(/on\w+\s*=/gi, '');
+ }
+
+ /**
+ * Sanitize CSS content for safe rendering
+ */
+ sanitizeCSS(css: string, options?: SanitizationOptions): string {
+ const defaultOptions: SanitizationOptions = {
+ removeImports: true,
+ allowedStyles: [
+ 'color',
+ 'background',
+ 'background-color',
+ 'background-image',
+ 'border',
+ 'border-radius',
+ 'border-color',
+ 'border-width',
+ 'padding',
+ 'margin',
+ 'width',
+ 'height',
+ 'max-width',
+ 'max-height',
+ 'min-width',
+ 'min-height',
+ 'display',
+ 'flex',
+ 'grid',
+ 'position',
+ 'top',
+ 'bottom',
+ 'left',
+ 'right',
+ 'font-size',
+ 'font-family',
+ 'font-weight',
+ 'text-align',
+ 'text-decoration',
+ 'line-height',
+ 'letter-spacing',
+ 'opacity',
+ 'visibility',
+ 'z-index',
+ 'overflow',
+ 'transform',
+ 'transition',
+ 'animation',
+ 'box-shadow',
+ 'cursor',
+ 'pointer-events',
+ ],
+ maxNesting: 3,
+ };
+
+ const mergedOptions = { ...defaultOptions, ...options };
+
+ let sanitized = css;
+
+ // Remove @import statements
+ if (mergedOptions.removeImports) {
+ sanitized = sanitized.replace(/@import\s+[^;]+;/gi, '');
+ }
+
+ // Remove javascript in CSS
+ sanitized = sanitized.replace(/javascript:/gi, '');
+ sanitized = sanitized.replace(/expression\s*\(/gi, '');
+ sanitized = sanitized.replace(/behavior\s*:/gi, '');
+ sanitized = sanitized.replace(/-moz-binding\s*:/gi, '');
+
+ // Remove external URLs (except for safe properties like background-image)
+ sanitized = sanitized.replace(
+ /url\s*\(\s*['"]?(?!data:)(?!https:\/\/[^'"]+\.(jpg|jpeg|png|gif|svg|webp))/gi,
+ 'url('
+ );
+
+ // Limit selector complexity (prevent performance issues)
+ const lines = sanitized.split('\n');
+ const processedLines = lines.map((line) => {
+ // Count selector depth
+ const selectorDepth = (line.match(/\s+/g) || []).length;
+ if (selectorDepth > (mergedOptions.maxNesting || 3)) {
+ return '/* Selector too deeply nested */';
+ }
+ return line;
+ });
+
+ sanitized = processedLines.join('\n');
+
+ // Remove potentially dangerous properties
+ const dangerousProperties = ['behavior', '-moz-binding', 'filter', 'content'];
+
+ dangerousProperties.forEach((prop) => {
+ const regex = new RegExp(`${prop}\\s*:([^;]+);`, 'gi');
+ sanitized = sanitized.replace(regex, '');
+ });
+
+ return sanitized;
+ }
+
+ /**
+ * Validate card constraints
+ */
+ validateConstraints(html: string, constraints: CardConstraints): boolean {
+ if (!constraints) return true;
+
+ // Create a temporary DOM element to analyze
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+
+ // Check for forbidden tags (if defined in constraints)
+ const allowedTags = (constraints as any).allowedTags;
+ if (allowedTags && Array.isArray(allowedTags)) {
+ const allTags = Array.from(doc.body.getElementsByTagName('*'));
+ for (const element of allTags) {
+ if (!allowedTags.includes(element.tagName.toLowerCase())) {
+ console.warn(`Forbidden tag found: ${element.tagName}`);
+ return false;
+ }
+ }
+ }
+
+ // Check for scripts (should already be removed by sanitizer)
+ if (constraints.preventScripts) {
+ if (doc.querySelector('script')) {
+ console.warn('Script tags are not allowed');
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Extract template variables from HTML
+ */
+ extractVariables(html: string): TemplateVariable[] {
+ const variables: TemplateVariable[] = [];
+ const seen = new Set();
+
+ // Match {{variable}} or {{variable:type}}
+ const regex = /\{\{(\w+)(?::(\w+))?\}\}/g;
+ let match;
+
+ while ((match = regex.exec(html)) !== null) {
+ const name = match[1];
+ const type = match[2] || 'text';
+
+ if (!seen.has(name)) {
+ seen.add(name);
+ variables.push({
+ name,
+ type: type as any,
+ required: true,
+ label: name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' '),
+ });
+ }
+ }
+
+ return variables;
+ }
+
+ /**
+ * Replace template variables with values
+ */
+ replaceVariables(template: string, values: Record): string {
+ let result = template;
+
+ // Replace {{variable}} patterns
+ Object.entries(values).forEach(([key, value]) => {
+ // Escape the value to prevent XSS
+ const escapedValue = this.escapeHtml(String(value || ''));
+ const regex = new RegExp(`\\{\\{${key}(?::\\w+)?\\}\\}`, 'g');
+ result = result.replace(regex, escapedValue);
+ });
+
+ // Remove any remaining unmatched variables
+ result = result.replace(/\{\{\w+(?::\w+)?\}\}/g, '');
+
+ return result;
+ }
+
+ /**
+ * Escape HTML characters
+ */
+ private escapeHtml(text: string): string {
+ const map: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ '/': '/',
+ };
+ return text.replace(/[&<>"'/]/g, (char) => map[char]);
+ }
+
+ /**
+ * Create safe iframe content
+ */
+ createSafeIframeContent(html: string, css: string, constraints?: CardConstraints): string {
+ const sanitizedHTML = this.sanitizeHTML(html);
+ const sanitizedCSS = this.sanitizeCSS(css);
+
+ // Build CSP meta tag
+ const csp = `
+ default-src 'none';
+ style-src 'unsafe-inline';
+ img-src data: https:;
+ font-src data: https:;
+ `
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ // Build the iframe content
+ const iframeContent = `
+
+
+
+
+
+
+
+
+
+
+ ${sanitizedHTML}
+
+
+
+ `;
+
+ return iframeContent;
+ }
+
+ /**
+ * Validate CSS property value
+ */
+ private isValidCSSValue(property: string, value: string): boolean {
+ // Basic validation for common properties
+ const validPatterns: Record = {
+ color: /^(#[0-9a-f]{3,8}|rgb|rgba|hsl|hsla|[a-z]+)$/i,
+ width: /^(\d+(%|px|em|rem|vw|vh)|auto|inherit)$/i,
+ height: /^(\d+(%|px|em|rem|vw|vh)|auto|inherit)$/i,
+ 'font-size': /^(\d+(%|px|em|rem)|inherit)$/i,
+ margin: /^(\d+(%|px|em|rem)|auto|inherit)$/i,
+ padding: /^(\d+(%|px|em|rem)|inherit)$/i,
+ };
+
+ const pattern = validPatterns[property];
+ if (pattern) {
+ return pattern.test(value.trim());
+ }
+
+ // Default: allow if no javascript
+ return !value.includes('javascript:') && !value.includes('expression(');
+ }
+}
+
+// Export singleton instance
+export const cardSanitizer = new CardSanitizer();
diff --git a/apps/uload/apps/web/src/lib/services/cardService.ts b/apps/uload/apps/web/src/lib/services/cardService.ts
new file mode 100644
index 000000000..2ce8511d3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/services/cardService.ts
@@ -0,0 +1,153 @@
+import { pb } from '$lib/pocketbase';
+import type {
+ Card,
+ CardConfig,
+ CardMetadata,
+ RenderMode,
+ DBCard,
+} from '$lib/components/cards/types';
+import { cardConverter } from './cardConverter';
+import { cardValidator } from './cardValidator';
+
+export class CardService {
+ /**
+ * Convert card between different modes
+ */
+ async convertCard(card: Card, targetMode: RenderMode): Promise {
+ let newConfig: CardConfig;
+
+ switch (targetMode) {
+ case 'beginner':
+ newConfig = await cardConverter.toModular(card.config);
+ break;
+ case 'advanced':
+ newConfig = await cardConverter.toTemplate(card.config);
+ break;
+ case 'expert':
+ newConfig = await cardConverter.toCustom(card.config);
+ break;
+ default:
+ throw new Error(`Unknown target mode: ${targetMode}`);
+ }
+
+ return {
+ ...card,
+ config: newConfig,
+ };
+ }
+
+ /**
+ * Save card to database
+ */
+ async saveCard(card: Card): Promise {
+ const userId = pb.authStore.model?.id;
+ if (!userId) throw new Error('User not authenticated');
+
+ // Validate card
+ const validation = cardValidator.validate(card);
+ if (!validation.valid) {
+ throw new Error(`Invalid card: ${validation.errors?.map((e) => e.message).join(', ')}`);
+ }
+
+ const dbCard: Partial = {
+ user_id: userId,
+ config: JSON.stringify(card.config),
+ metadata: JSON.stringify(card.metadata),
+ constraints: JSON.stringify(card.constraints),
+ variant: card.variant,
+ };
+
+ let result;
+ if (card.id && card.id !== 'new') {
+ // Update existing
+ result = await pb.collection('cards').update(card.id, dbCard);
+ } else {
+ // Create new
+ result = await pb.collection('cards').create(dbCard);
+ }
+
+ return result.id;
+ }
+
+ /**
+ * Load card from database
+ */
+ async loadCard(id: string): Promise {
+ try {
+ const dbCard = await pb.collection('cards').getOne(id);
+ return this.dbCardToCard(dbCard);
+ } catch (error) {
+ console.error('Error loading card:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Load user's cards
+ */
+ async loadUserCards(filters?: { page?: string; limit?: number }): Promise {
+ const userId = pb.authStore.model?.id;
+ if (!userId) return [];
+
+ let filter = `user_id = "${userId}"`;
+ if (filters?.page) {
+ filter += ` && metadata.page = "${filters.page}"`;
+ }
+
+ const records = await pb.collection('cards').getList(1, filters?.limit || 100, {
+ filter,
+ sort: 'metadata.position,created',
+ });
+
+ return records.items.map((item) => this.dbCardToCard(item));
+ }
+
+ /**
+ * Delete card
+ */
+ async deleteCard(id: string): Promise {
+ try {
+ await pb.collection('cards').delete(id);
+ return true;
+ } catch (error) {
+ console.error('Error deleting card:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Duplicate card
+ */
+ async duplicateCard(card: Card): Promise {
+ const newCard: Card = {
+ ...card,
+ id: 'new',
+ metadata: {
+ ...card.metadata,
+ name: `${card.metadata?.name || 'Card'} (Copy)`,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ },
+ };
+
+ const newId = await this.saveCard(newCard);
+ newCard.id = newId;
+ return newCard;
+ }
+
+ /**
+ * Convert database card to Card type
+ */
+ private dbCardToCard(dbCard: DBCard): Card {
+ return {
+ id: dbCard.id,
+ config: JSON.parse(dbCard.config),
+ metadata: JSON.parse(dbCard.metadata),
+ constraints: JSON.parse(dbCard.constraints || '{}'),
+ variant: dbCard.variant as any,
+ };
+ }
+}
+
+// Export singleton instance
+export const cardService = new CardService();
diff --git a/apps/uload/apps/web/src/lib/services/cardValidator.ts b/apps/uload/apps/web/src/lib/services/cardValidator.ts
new file mode 100644
index 000000000..24d0faaa3
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/services/cardValidator.ts
@@ -0,0 +1,454 @@
+import type { Card, CardConfig, ValidationResult, Module } from '$lib/components/cards/types';
+
+class CardValidator {
+ /**
+ * Validate a complete card
+ */
+ validate(card: Card): ValidationResult {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ // Validate ID
+ if (!card.id || card.id.trim() === '') {
+ errors.push({ field: 'id', message: 'Card ID is required' });
+ }
+
+ // Validate config
+ const configErrors = this.validateConfig(card.config);
+ errors.push(...configErrors);
+
+ // Validate constraints
+ const constraintErrors = this.validateConstraints(card);
+ errors.push(...constraintErrors);
+
+ // Validate metadata
+ const metadataErrors = this.validateMetadata(card);
+ errors.push(...metadataErrors);
+
+ return {
+ valid: errors.length === 0,
+ errors: errors.length > 0 ? errors : undefined,
+ };
+ }
+
+ /**
+ * Validate card configuration based on mode
+ */
+ private validateConfig(config: CardConfig): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ if (!config.mode) {
+ errors.push({ field: 'config.mode', message: 'Card mode is required' });
+ return errors;
+ }
+
+ switch (config.mode) {
+ case 'beginner':
+ errors.push(...this.validateBeginnerConfig(config));
+ break;
+ case 'advanced':
+ errors.push(...this.validateAdvancedConfig(config));
+ break;
+ case 'expert':
+ errors.push(...this.validateExpertConfig(config));
+ break;
+ default:
+ // This should never happen with proper TypeScript types, but kept for runtime safety
+ const _exhaustiveCheck: never = config;
+ errors.push({ field: 'config.mode', message: `Invalid mode: ${(config as any).mode}` });
+ }
+
+ return errors;
+ }
+
+ /**
+ * Validate beginner mode configuration
+ */
+ private validateBeginnerConfig(
+ config: Extract
+ ): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ // Validate modules
+ if (!Array.isArray(config.modules)) {
+ errors.push({ field: 'config.modules', message: 'Modules must be an array' });
+ } else {
+ // Check module count
+ if (config.modules.length === 0) {
+ errors.push({ field: 'config.modules', message: 'At least one module is required' });
+ }
+ if (config.modules.length > 20) {
+ errors.push({ field: 'config.modules', message: 'Maximum 20 modules allowed' });
+ }
+
+ // Validate each module
+ config.modules.forEach((module, index) => {
+ errors.push(...this.validateModule(module, index));
+ });
+ }
+
+ // Validate layout
+ if (config.layout) {
+ if (config.layout.columns && (config.layout.columns < 1 || config.layout.columns > 4)) {
+ errors.push({
+ field: 'config.layout.columns',
+ message: 'Columns must be between 1 and 4',
+ });
+ }
+ }
+
+ return errors;
+ }
+
+ /**
+ * Validate a single module
+ */
+ private validateModule(module: Module, index: number): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+ const prefix = `config.modules[${index}]`;
+
+ if (!module.id) {
+ errors.push({ field: `${prefix}.id`, message: 'Module ID is required' });
+ }
+
+ if (!module.type) {
+ errors.push({ field: `${prefix}.type`, message: 'Module type is required' });
+ } else {
+ const validTypes = [
+ 'header',
+ 'content',
+ 'footer',
+ 'media',
+ 'stats',
+ 'actions',
+ 'links',
+ 'custom',
+ ];
+ if (!validTypes.includes(module.type)) {
+ errors.push({ field: `${prefix}.type`, message: `Invalid module type: ${module.type}` });
+ }
+ }
+
+ if (typeof module.order !== 'number') {
+ errors.push({ field: `${prefix}.order`, message: 'Module order must be a number' });
+ }
+
+ // Validate module-specific props
+ if (module.type === 'links' && module.props) {
+ if (!Array.isArray(module.props.links)) {
+ errors.push({ field: `${prefix}.props.links`, message: 'Links must be an array' });
+ }
+ }
+
+ if (module.type === 'media' && module.props) {
+ if (!module.props.type) {
+ errors.push({ field: `${prefix}.props.type`, message: 'Media type is required' });
+ }
+ if (module.props.type === 'image' && !module.props.src) {
+ errors.push({ field: `${prefix}.props.src`, message: 'Image source is required' });
+ }
+ }
+
+ return errors;
+ }
+
+ /**
+ * Validate advanced mode configuration
+ */
+ private validateAdvancedConfig(
+ config: Extract
+ ): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ // Validate template
+ if (!config.template || config.template.trim() === '') {
+ errors.push({ field: 'config.template', message: 'Template is required' });
+ } else {
+ // Check template size
+ if (config.template.length > 100000) {
+ errors.push({
+ field: 'config.template',
+ message: 'Template exceeds maximum size of 100KB',
+ });
+ }
+
+ // Check for dangerous patterns
+ if (this.containsDangerousPatterns(config.template)) {
+ errors.push({
+ field: 'config.template',
+ message: 'Template contains potentially dangerous patterns',
+ });
+ }
+ }
+
+ // Validate CSS
+ if (config.css) {
+ if (config.css.length > 50000) {
+ errors.push({
+ field: 'config.css',
+ message: 'CSS exceeds maximum size of 50KB',
+ });
+ }
+
+ if (this.containsDangerousCSS(config.css)) {
+ errors.push({
+ field: 'config.css',
+ message: 'CSS contains potentially dangerous patterns',
+ });
+ }
+ }
+
+ // Validate variables
+ if (!Array.isArray(config.variables)) {
+ errors.push({ field: 'config.variables', message: 'Variables must be an array' });
+ } else {
+ config.variables.forEach((variable, index) => {
+ if (!variable.name) {
+ errors.push({
+ field: `config.variables[${index}].name`,
+ message: 'Variable name is required',
+ });
+ }
+ if (!variable.type) {
+ errors.push({
+ field: `config.variables[${index}].type`,
+ message: 'Variable type is required',
+ });
+ }
+ });
+ }
+
+ // Validate values match variables
+ if (config.values && config.variables) {
+ const requiredVars = config.variables.filter((v) => v.required);
+ requiredVars.forEach((variable) => {
+ if (!(variable.name in config.values)) {
+ errors.push({
+ field: `config.values.${variable.name}`,
+ message: `Required variable '${variable.name}' is missing`,
+ });
+ }
+ });
+ }
+
+ return errors;
+ }
+
+ /**
+ * Validate expert mode configuration
+ */
+ private validateExpertConfig(
+ config: Extract
+ ): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ // Validate HTML
+ if (!config.html || config.html.trim() === '') {
+ errors.push({ field: 'config.html', message: 'HTML is required' });
+ } else {
+ if (config.html.length > 100000) {
+ errors.push({
+ field: 'config.html',
+ message: 'HTML exceeds maximum size of 100KB',
+ });
+ }
+
+ if (this.containsDangerousPatterns(config.html)) {
+ errors.push({
+ field: 'config.html',
+ message: 'HTML contains potentially dangerous patterns',
+ });
+ }
+ }
+
+ // Validate CSS
+ if (!config.css || config.css.trim() === '') {
+ errors.push({ field: 'config.css', message: 'CSS is required' });
+ } else {
+ if (config.css.length > 50000) {
+ errors.push({
+ field: 'config.css',
+ message: 'CSS exceeds maximum size of 50KB',
+ });
+ }
+
+ if (this.containsDangerousCSS(config.css)) {
+ errors.push({
+ field: 'config.css',
+ message: 'CSS contains potentially dangerous patterns',
+ });
+ }
+ }
+
+ // JavaScript is not allowed in expert mode for security
+ if (config.javascript) {
+ errors.push({
+ field: 'config.javascript',
+ message: 'JavaScript is not allowed for security reasons',
+ });
+ }
+
+ return errors;
+ }
+
+ /**
+ * Validate card constraints
+ */
+ private validateConstraints(card: Card): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ if (!card.constraints) {
+ return errors;
+ }
+
+ // Validate aspect ratio
+ if (card.constraints.aspectRatio) {
+ const validRatios = ['16/9', '4/3', '1/1', '3/2', 'auto'];
+ if (!validRatios.includes(card.constraints.aspectRatio)) {
+ // Check if it's a custom ratio like "21/9"
+ const ratioPattern = /^\d+\/\d+$/;
+ if (!ratioPattern.test(card.constraints.aspectRatio)) {
+ errors.push({
+ field: 'constraints.aspectRatio',
+ message: 'Invalid aspect ratio format',
+ });
+ }
+ }
+ }
+
+ // Validate size constraints
+ if (card.constraints.maxModules && card.constraints.maxModules < 1) {
+ errors.push({
+ field: 'constraints.maxModules',
+ message: 'Maximum modules must be at least 1',
+ });
+ }
+
+ if (card.constraints.maxHTMLSize && card.constraints.maxHTMLSize < 1000) {
+ errors.push({
+ field: 'constraints.maxHTMLSize',
+ message: 'Maximum HTML size must be at least 1KB',
+ });
+ }
+
+ if (card.constraints.maxCSSSize && card.constraints.maxCSSSize < 1000) {
+ errors.push({
+ field: 'constraints.maxCSSSize',
+ message: 'Maximum CSS size must be at least 1KB',
+ });
+ }
+
+ return errors;
+ }
+
+ /**
+ * Validate card metadata
+ */
+ private validateMetadata(card: Card): Array<{ field: string; message: string }> {
+ const errors: Array<{ field: string; message: string }> = [];
+
+ if (!card.metadata) {
+ return errors;
+ }
+
+ // Validate name length
+ if (card.metadata.name && card.metadata.name.length > 100) {
+ errors.push({
+ field: 'metadata.name',
+ message: 'Name must be 100 characters or less',
+ });
+ }
+
+ // Validate description length
+ if (card.metadata.description && card.metadata.description.length > 500) {
+ errors.push({
+ field: 'metadata.description',
+ message: 'Description must be 500 characters or less',
+ });
+ }
+
+ // Validate tags
+ if (card.metadata.tags) {
+ if (!Array.isArray(card.metadata.tags)) {
+ errors.push({
+ field: 'metadata.tags',
+ message: 'Tags must be an array',
+ });
+ } else if (card.metadata.tags.length > 10) {
+ errors.push({
+ field: 'metadata.tags',
+ message: 'Maximum 10 tags allowed',
+ });
+ }
+ }
+
+ // Position is now directly on the Card, not in metadata
+ // No need to validate here since it's handled at the Card level
+
+ return errors;
+ }
+
+ /**
+ * Check for dangerous HTML patterns
+ */
+ private containsDangerousPatterns(html: string): boolean {
+ const dangerousPatterns = [
+ /
+
+
+
Titel
+
Beschreibung
+
+ Action
+
+
+```
+
+## Theme Transitions
+
+Theme-Wechsel werden automatisch mit sanften Übergängen animiert. Die Klasse `theme-transitioning` wird während des Wechsels auf das HTML-Element angewendet.
+
+## Best Practices
+
+1. **Verwende immer Theme-Farben** anstatt hardcodierte Farben
+2. **Teste neue Komponenten** mit allen verfügbaren Themes
+3. **Beachte Kontraste** für Barrierefreiheit
+4. **Nutze semantische Farbnamen** (primary, accent) statt spezifischer Farben (blue, green)
diff --git a/apps/uload/apps/web/src/lib/themes/presets.ts b/apps/uload/apps/web/src/lib/themes/presets.ts
new file mode 100644
index 000000000..24ca279ef
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/themes/presets.ts
@@ -0,0 +1,207 @@
+export interface ColorScheme {
+ primary: string;
+ primaryHover: string;
+ background: string;
+ surface: string;
+ surfaceHover: string;
+ text: string;
+ textMuted: string;
+ border: string;
+ accent: string;
+ accentHover: string;
+}
+
+export interface ThemePreset {
+ id: string;
+ name: string;
+ description: string;
+ font: {
+ family: string;
+ import?: string; // Google Fonts import URL
+ };
+ colors: {
+ light: ColorScheme;
+ dark: ColorScheme;
+ };
+}
+
+export const themes: Record = {
+ minimal: {
+ id: 'minimal',
+ name: 'Minimal',
+ description: 'Ruhiges, minimalistisches Design',
+ font: {
+ family: 'Inter, system-ui, -apple-system, sans-serif',
+ import: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
+ },
+ colors: {
+ light: {
+ primary: '#171717',
+ primaryHover: '#0a0a0a',
+ background: '#f5f5f5',
+ surface: '#fafafa',
+ surfaceHover: '#eeeeee',
+ text: '#171717',
+ textMuted: '#737373',
+ border: '#d4d4d4',
+ accent: '#525252',
+ accentHover: '#404040',
+ },
+ dark: {
+ primary: '#b8b8b8',
+ primaryHover: '#ffffff',
+ background: '#0a0a0a',
+ surface: '#171717',
+ surfaceHover: '#262626',
+ text: '#fafafa',
+ textMuted: '#a3a3a3',
+ border: '#404040',
+ accent: '#d4d4d4',
+ accentHover: '#e5e5e5',
+ },
+ },
+ },
+ ocean: {
+ id: 'ocean',
+ name: 'Ocean',
+ description: 'Beruhigende Blautöne',
+ font: {
+ family: 'Poppins, system-ui, -apple-system, sans-serif',
+ import: 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap',
+ },
+ colors: {
+ light: {
+ primary: '#0ea5e9',
+ primaryHover: '#0284c7',
+ background: '#e0f2fe',
+ surface: '#f0f9ff',
+ surfaceHover: '#bae6fd',
+ text: '#0c4a6e',
+ textMuted: '#475569',
+ border: '#7dd3fc',
+ accent: '#06b6d4',
+ accentHover: '#0891b2',
+ },
+ dark: {
+ primary: '#38bdf8',
+ primaryHover: '#7dd3fc',
+ background: '#082f49',
+ surface: '#0c4a6e',
+ surfaceHover: '#075985',
+ text: '#f0f9ff',
+ textMuted: '#94a3b8',
+ border: '#1e3a8a',
+ accent: '#22d3ee',
+ accentHover: '#67e8f9',
+ },
+ },
+ },
+ forest: {
+ id: 'forest',
+ name: 'Forest',
+ description: 'Natürliche Grüntöne',
+ font: {
+ family: 'Lora, Georgia, serif',
+ import: 'https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap',
+ },
+ colors: {
+ light: {
+ primary: '#16a34a',
+ primaryHover: '#15803d',
+ background: '#dcfce7',
+ surface: '#f0fdf4',
+ surfaceHover: '#bbf7d0',
+ text: '#14532d',
+ textMuted: '#4b5563',
+ border: '#86efac',
+ accent: '#84cc16',
+ accentHover: '#65a30d',
+ },
+ dark: {
+ primary: '#4ade80',
+ primaryHover: '#86efac',
+ background: '#052e16',
+ surface: '#14532d',
+ surfaceHover: '#166534',
+ text: '#f0fdf4',
+ textMuted: '#86b896',
+ border: '#15803d',
+ accent: '#a3e635',
+ accentHover: '#bef264',
+ },
+ },
+ },
+ sunset: {
+ id: 'sunset',
+ name: 'Sunset',
+ description: 'Warme Orange- und Rottöne',
+ font: {
+ family: 'Raleway, system-ui, -apple-system, sans-serif',
+ import: 'https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap',
+ },
+ colors: {
+ light: {
+ primary: '#ea580c',
+ primaryHover: '#c2410c',
+ background: '#fed7aa',
+ surface: '#fff7ed',
+ surfaceHover: '#fdba74',
+ text: '#7c2d12',
+ textMuted: '#57534e',
+ border: '#fb923c',
+ accent: '#f97316',
+ accentHover: '#fb923c',
+ },
+ dark: {
+ primary: '#fb923c',
+ primaryHover: '#fdba74',
+ background: '#431407',
+ surface: '#7c2d12',
+ surfaceHover: '#9a3412',
+ text: '#fff7ed',
+ textMuted: '#94a3b8',
+ border: '#c2410c',
+ accent: '#fbbf24',
+ accentHover: '#fcd34d',
+ },
+ },
+ },
+ lavender: {
+ id: 'lavender',
+ name: 'Lavender',
+ description: 'Sanfte Violett-Töne',
+ font: {
+ family: 'Playfair Display, Georgia, serif',
+ import:
+ 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&display=swap',
+ },
+ colors: {
+ light: {
+ primary: '#9333ea',
+ primaryHover: '#7e22ce',
+ background: '#f3e8ff',
+ surface: '#faf5ff',
+ surfaceHover: '#e9d5ff',
+ text: '#581c87',
+ textMuted: '#525252',
+ border: '#d8b4fe',
+ accent: '#a855f7',
+ accentHover: '#c084fc',
+ },
+ dark: {
+ primary: '#c084fc',
+ primaryHover: '#d8b4fe',
+ background: '#3b0764',
+ surface: '#581c87',
+ surfaceHover: '#6b21a8',
+ text: '#faf5ff',
+ textMuted: '#94a3b8',
+ border: '#7e22ce',
+ accent: '#d946ef',
+ accentHover: '#e879f9',
+ },
+ },
+ },
+};
+
+export const defaultTheme = 'minimal';
diff --git a/apps/uload/apps/web/src/lib/themes/theme-store.ts b/apps/uload/apps/web/src/lib/themes/theme-store.ts
new file mode 100644
index 000000000..04f864d88
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/themes/theme-store.ts
@@ -0,0 +1,181 @@
+import { browser } from '$app/environment';
+import { themes, defaultTheme } from './presets';
+import type { ThemePreset } from './presets';
+import { writable, derived, get } from 'svelte/store';
+
+export type ThemeMode = 'light' | 'dark' | 'system';
+
+class ThemeStore {
+ // Using Svelte stores instead of runes for SSR compatibility
+ private presetStore = writable(defaultTheme);
+ private modeStore = writable('system');
+ private systemPrefersDarkStore = writable(false);
+ private transitioningStore = writable(false);
+
+ // Public readable stores
+ public preset = { subscribe: this.presetStore.subscribe };
+ public mode = { subscribe: this.modeStore.subscribe };
+ public transitioning = { subscribe: this.transitioningStore.subscribe };
+
+ // Derived stores
+ public currentPreset = derived(
+ this.presetStore,
+ ($preset) => themes[$preset] || themes[defaultTheme]
+ );
+
+ public isDark = derived(
+ [this.modeStore, this.systemPrefersDarkStore],
+ ([$mode, $systemPrefersDark]) => {
+ return $mode === 'system' ? $systemPrefersDark : $mode === 'dark';
+ }
+ );
+
+ public colors = derived([this.currentPreset, this.isDark], ([$currentPreset, $isDark]) => {
+ return $isDark ? $currentPreset.colors.dark : $currentPreset.colors.light;
+ });
+
+ public font = derived(this.currentPreset, ($currentPreset) => $currentPreset.font);
+
+ constructor() {
+ if (browser) {
+ this.init();
+ }
+ }
+
+ private init() {
+ // Load saved preferences
+ const savedPreset = localStorage.getItem('theme-preset');
+ const savedMode = localStorage.getItem('theme-mode') as ThemeMode;
+
+ if (savedPreset && themes[savedPreset]) {
+ this.presetStore.set(savedPreset);
+ }
+
+ if (savedMode) {
+ this.modeStore.set(savedMode);
+ }
+
+ // Detect system preference
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ this.systemPrefersDarkStore.set(mediaQuery.matches);
+
+ mediaQuery.addEventListener('change', (e) => {
+ this.systemPrefersDarkStore.set(e.matches);
+ if (get(this.modeStore) === 'system') {
+ this.applyTheme();
+ }
+ });
+
+ // Apply initial theme
+ this.applyTheme();
+
+ // Subscribe to changes
+ this.presetStore.subscribe(() => this.applyTheme());
+ this.modeStore.subscribe(() => this.applyTheme());
+ this.isDark.subscribe(() => this.applyTheme());
+ }
+
+ // Apply theme to DOM
+ applyTheme() {
+ if (!browser) return;
+
+ const root = document.documentElement;
+ const colors = get(this.colors);
+ const font = get(this.font);
+ const isDark = get(this.isDark);
+
+ // Apply dark class
+ if (isDark) {
+ root.classList.add('dark');
+ } else {
+ root.classList.remove('dark');
+ }
+
+ // Apply CSS variables
+ Object.entries(colors).forEach(([key, value]) => {
+ // Convert camelCase to kebab-case for CSS variables
+ const cssKey = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
+ const varName = `--theme-${cssKey}`;
+ root.style.setProperty(varName, value);
+ });
+
+ // Apply font
+ root.style.setProperty('--theme-font-family', font.family);
+
+ // Load Google Font if needed
+ if (font.import) {
+ const preset = get(this.presetStore);
+ const fontId = `theme-font-${preset}`;
+ let existingFont = document.getElementById(fontId);
+
+ // Remove old font links
+ document.querySelectorAll('link[id^="theme-font-"]').forEach((link) => {
+ if (link.id !== fontId) {
+ link.remove();
+ }
+ });
+
+ // Add new font link if not exists
+ if (!existingFont) {
+ const link = document.createElement('link');
+ link.id = fontId;
+ link.rel = 'stylesheet';
+ link.href = font.import;
+ document.head.appendChild(link);
+ }
+ }
+
+ // Save to localStorage
+ localStorage.setItem('theme-preset', get(this.presetStore));
+ localStorage.setItem('theme-mode', get(this.modeStore));
+ }
+
+ // Change theme preset with transition
+ async setPreset(presetId: string) {
+ if (!themes[presetId]) return;
+
+ if (browser) {
+ this.transitioningStore.set(true);
+ document.documentElement.classList.add('theme-transitioning');
+
+ // Small delay for transition start
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ this.presetStore.set(presetId);
+
+ // Wait for transition to complete
+ await new Promise((resolve) => setTimeout(resolve, 300));
+
+ document.documentElement.classList.remove('theme-transitioning');
+ this.transitioningStore.set(false);
+ } else {
+ this.presetStore.set(presetId);
+ }
+ }
+
+ // Change theme mode
+ setMode(mode: ThemeMode) {
+ this.modeStore.set(mode);
+ }
+
+ // Toggle between light and dark
+ toggle() {
+ const currentMode = get(this.modeStore);
+ const systemPrefersDark = get(this.systemPrefersDarkStore);
+
+ if (currentMode === 'system') {
+ // If system mode, switch to opposite of current system preference
+ this.modeStore.set(systemPrefersDark ? 'light' : 'dark');
+ } else {
+ // Toggle between light and dark
+ this.modeStore.set(currentMode === 'light' ? 'dark' : 'light');
+ }
+ }
+
+ // Get all available themes
+ get availableThemes() {
+ return Object.values(themes);
+ }
+}
+
+export const themeStore = new ThemeStore();
diff --git a/apps/uload/apps/web/src/lib/types/accounts.ts b/apps/uload/apps/web/src/lib/types/accounts.ts
new file mode 100644
index 000000000..7f32c595a
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/types/accounts.ts
@@ -0,0 +1,146 @@
+// Simplified Account Types for Team Collaboration
+
+export interface User {
+ id: string;
+ email: string;
+ username: string;
+ name?: string;
+ avatar?: string;
+ bio?: string;
+ location?: string;
+ website?: string;
+ github?: string;
+ twitter?: string;
+ linkedin?: string;
+ instagram?: string;
+ showClickStats?: boolean;
+ subscription_status?: 'free' | 'pro' | 'team' | 'team_plus' | 'cancelled' | 'past_due';
+ stripe_customer_id?: string;
+ stripe_subscription_id?: string;
+ current_period_end?: string;
+ links_created_this_month?: number;
+ monthly_reset_date?: string;
+ team_members_count?: number;
+ created: string;
+ updated: string;
+ verified?: boolean;
+}
+
+// Shared access for team collaboration
+export interface SharedAccess {
+ id: string;
+ owner: string; // User who owns the account
+ user: string; // User who has access
+ permissions?: TeamPermissions;
+ invitation_token?: string;
+ invitation_status?: 'pending' | 'accepted' | 'declined';
+ invited_at?: string;
+ accepted_at?: string;
+ created: string;
+ updated: string;
+ expand?: {
+ owner?: User;
+ user?: User;
+ };
+}
+
+// Team member permissions
+export interface TeamPermissions {
+ view_stats: boolean;
+ create_links: boolean;
+ edit_own: boolean;
+ delete_own: boolean;
+ manage_team?: boolean; // Only for team admins
+}
+
+// Default permissions for new team members
+export const DEFAULT_PERMISSIONS: TeamPermissions = {
+ view_stats: true,
+ create_links: true,
+ edit_own: true,
+ delete_own: true,
+ manage_team: false,
+};
+
+// Subscription plans with updated limits
+export const SUBSCRIPTION_PLANS = {
+ free: {
+ name: 'Free',
+ price: 0,
+ currency: 'EUR',
+ team_members: 1, // Can invite 1 team member
+ links_per_month: 10, // Updated to match pricing page
+ features: [
+ '10 links per month',
+ '1 team member',
+ 'Basic Analytics',
+ 'QR Codes',
+ 'Link Customization',
+ ],
+ },
+ pro: {
+ name: 'Pro Monthly',
+ price: 4.99,
+ currency: 'EUR',
+ team_members: 3, // Can invite up to 3 team members
+ links_per_month: 300, // Updated to match pricing page
+ features: [
+ '300 links per month',
+ 'Up to 3 team members',
+ 'Advanced Analytics',
+ 'Custom QR Codes',
+ 'Priority Support',
+ ],
+ },
+ team: {
+ name: 'Pro Yearly',
+ price: 39.99,
+ currency: 'EUR',
+ team_members: 5, // Can invite up to 5 team members
+ links_per_month: 600, // Updated to match pricing page (yearly = 600/month)
+ features: [
+ '600 links per month',
+ 'Up to 5 team members',
+ 'Advanced Analytics',
+ 'Custom QR Codes',
+ 'Priority Support',
+ ],
+ },
+ team_plus: {
+ name: 'Pro Lifetime',
+ price: 129.99,
+ currency: 'EUR',
+ team_members: -1, // unlimited team members
+ links_per_month: -1, // unlimited
+ features: [
+ 'Unlimited links',
+ 'Unlimited team members',
+ 'All Pro Features',
+ 'API Access',
+ 'Early Access to new Features',
+ ],
+ },
+};
+
+// Helper to check if user can add team members (now everyone can)
+export function canAddTeamMembers(subscription_status?: string): boolean {
+ return true; // Everyone can invite team members
+}
+
+// Helper to get team member limit
+export function getTeamMemberLimit(subscription_status?: string): number {
+ if (!subscription_status || !(subscription_status in SUBSCRIPTION_PLANS)) {
+ return SUBSCRIPTION_PLANS.free.team_members; // Default to free plan limit
+ }
+ const limit =
+ SUBSCRIPTION_PLANS[subscription_status as keyof typeof SUBSCRIPTION_PLANS].team_members;
+ return limit === -1 ? Infinity : limit; // -1 means unlimited
+}
+
+// Helper to get links per month limit
+export function getLinksPerMonthLimit(subscription_status?: string): number {
+ if (!subscription_status || !(subscription_status in SUBSCRIPTION_PLANS)) {
+ return SUBSCRIPTION_PLANS.free.links_per_month;
+ }
+ return SUBSCRIPTION_PLANS[subscription_status as keyof typeof SUBSCRIPTION_PLANS].links_per_month;
+}
diff --git a/apps/uload/apps/web/src/lib/username.spec.ts b/apps/uload/apps/web/src/lib/username.spec.ts
new file mode 100644
index 000000000..2c42059bb
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/username.spec.ts
@@ -0,0 +1,171 @@
+import { describe, it, expect } from 'vitest';
+import { validateUsername, generateUsernameFromEmail, RESERVED_USERNAMES } from './username';
+
+describe('Username Utilities', () => {
+ describe('validateUsername', () => {
+ it('should accept valid usernames', () => {
+ const validUsernames = [
+ 'john_doe',
+ 'user123',
+ 'test-user',
+ 'JohnDoe',
+ 'a1b2c3',
+ 'user_name-123',
+ ];
+
+ validUsernames.forEach((username) => {
+ const result = validateUsername(username);
+ expect(result.valid).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+ });
+
+ it('should reject usernames shorter than 3 characters', () => {
+ const result = validateUsername('ab');
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('at least 3 characters');
+ });
+
+ it('should reject usernames longer than 30 characters', () => {
+ const longUsername = 'a'.repeat(31);
+ const result = validateUsername(longUsername);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('less than 30 characters');
+ });
+
+ it('should reject usernames with special characters', () => {
+ const invalidUsernames = [
+ 'user@name',
+ 'user.name',
+ 'user name',
+ 'user!name',
+ 'user#name',
+ 'user$name',
+ ];
+
+ invalidUsernames.forEach((username) => {
+ const result = validateUsername(username);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('letters, numbers, underscore and hyphen');
+ });
+ });
+
+ it('should reject usernames not starting with letter or number', () => {
+ const invalidStarts = ['_username', '-username', '__test'];
+
+ invalidStarts.forEach((username) => {
+ const result = validateUsername(username);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('start with a letter or number');
+ });
+ });
+
+ it('should reject reserved usernames', () => {
+ const reserved = ['admin', 'api', 'dashboard', 'login', 'settings'];
+
+ reserved.forEach((username) => {
+ const result = validateUsername(username);
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('reserved');
+ });
+ });
+
+ it('should reject reserved usernames case-insensitively', () => {
+ const result = validateUsername('ADMIN');
+ expect(result.valid).toBe(false);
+ expect(result.error).toContain('reserved');
+ });
+ });
+
+ describe('generateUsernameFromEmail', () => {
+ it('should extract local part from email', () => {
+ const username = generateUsernameFromEmail('john.doe@example.com');
+ expect(username).toContain('john');
+ expect(username).not.toContain('@');
+ expect(username).not.toContain('example.com');
+ });
+
+ it('should remove special characters', () => {
+ const username = generateUsernameFromEmail('john.doe+test@example.com');
+ expect(username).toBe('johndoetest');
+ });
+
+ it('should handle emails with numbers', () => {
+ const username = generateUsernameFromEmail('user123@example.com');
+ expect(username).toBe('user123');
+ });
+
+ it('should preserve underscores and hyphens', () => {
+ const username = generateUsernameFromEmail('john_doe-123@example.com');
+ expect(username).toBe('john_doe-123');
+ });
+
+ it('should add prefix if starting with invalid character', () => {
+ const username = generateUsernameFromEmail('_test@example.com');
+ expect(username).toMatch(/^user_test/);
+ });
+
+ it('should ensure minimum length of 3', () => {
+ const username = generateUsernameFromEmail('a@example.com');
+ expect(username.length).toBeGreaterThanOrEqual(3);
+ expect(username).toMatch(/^a[a-z0-9]+$/);
+ });
+
+ it('should truncate if longer than 30 characters', () => {
+ const longEmail = 'a'.repeat(40) + '@example.com';
+ const username = generateUsernameFromEmail(longEmail);
+ expect(username.length).toBeLessThanOrEqual(30);
+ });
+
+ it('should handle empty local part', () => {
+ const username = generateUsernameFromEmail('@example.com');
+ expect(username.length).toBeGreaterThanOrEqual(3);
+ expect(username).toMatch(/^user/);
+ });
+
+ it('should handle complex email formats', () => {
+ const testCases = [
+ { email: 'first.last@example.com', expected: 'firstlast' },
+ { email: 'user+tag@example.com', expected: 'usertag' },
+ { email: '123user@example.com', expected: '123user' },
+ { email: 'test.test.test@example.com', expected: 'testtesttest' },
+ ];
+
+ testCases.forEach(({ email, expected }) => {
+ const username = generateUsernameFromEmail(email);
+ expect(username).toBe(expected);
+ });
+ });
+ });
+
+ describe('RESERVED_USERNAMES', () => {
+ it('should contain common reserved names', () => {
+ const essentialReserved = [
+ 'admin',
+ 'api',
+ 'login',
+ 'logout',
+ 'register',
+ 'settings',
+ 'dashboard',
+ 'user',
+ 'users',
+ ];
+
+ essentialReserved.forEach((name) => {
+ expect(RESERVED_USERNAMES).toContain(name);
+ });
+ });
+
+ it('should not have duplicates', () => {
+ const uniqueNames = new Set(RESERVED_USERNAMES);
+ expect(uniqueNames.size).toBe(RESERVED_USERNAMES.length);
+ });
+
+ it('should be all lowercase', () => {
+ RESERVED_USERNAMES.forEach((name) => {
+ expect(name).toBe(name.toLowerCase());
+ });
+ });
+ });
+});
diff --git a/apps/uload/apps/web/src/lib/username.ts b/apps/uload/apps/web/src/lib/username.ts
new file mode 100644
index 000000000..093078f41
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/username.ts
@@ -0,0 +1,107 @@
+// Reserved usernames that cannot be used
+export const RESERVED_USERNAMES = [
+ 'admin',
+ 'api',
+ 'app',
+ 'blog',
+ 'dashboard',
+ 'help',
+ 'login',
+ 'logout',
+ 'register',
+ 'settings',
+ 'support',
+ 'www',
+ 'mail',
+ 'ftp',
+ 'email',
+ 'about',
+ 'privacy',
+ 'terms',
+ 'security',
+ 'contact',
+ 'legal',
+ 'docs',
+ 'documentation',
+ 'status',
+ 'cdn',
+ 'assets',
+ 'public',
+ 'static',
+ 'media',
+ 'css',
+ 'js',
+ 'images',
+ 'img',
+ 'fonts',
+ 'download',
+ 'downloads',
+ 'u',
+ 'user',
+ 'users',
+ 'profile',
+ 'account',
+ 'accounts',
+ 'auth',
+ 'oauth',
+ 'signin',
+ 'signup',
+ 'signout',
+ 'reset',
+ 'verify',
+ 'confirm',
+ 'analytics',
+];
+
+export function validateUsername(username: string): { valid: boolean; error?: string } {
+ // Check length
+ if (username.length < 3) {
+ return { valid: false, error: 'Username must be at least 3 characters' };
+ }
+ if (username.length > 30) {
+ return { valid: false, error: 'Username must be less than 30 characters' };
+ }
+
+ // Check format (alphanumeric, underscore, hyphen)
+ if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
+ return {
+ valid: false,
+ error: 'Username can only contain letters, numbers, underscore and hyphen',
+ };
+ }
+
+ // Must start with letter or number
+ if (!/^[a-zA-Z0-9]/.test(username)) {
+ return { valid: false, error: 'Username must start with a letter or number' };
+ }
+
+ // Check reserved names
+ if (RESERVED_USERNAMES.includes(username.toLowerCase())) {
+ return { valid: false, error: 'This username is reserved' };
+ }
+
+ return { valid: true };
+}
+
+export function generateUsernameFromEmail(email: string): string {
+ const localPart = email.split('@')[0];
+ // Remove special characters and convert to valid username
+ let username = localPart.replace(/[^a-zA-Z0-9_-]/g, '');
+
+ // Ensure it starts with letter or number
+ if (!/^[a-zA-Z0-9]/.test(username)) {
+ username = 'user' + username;
+ }
+
+ // Ensure minimum length
+ if (username.length < 3) {
+ username = username + Math.random().toString(36).substring(2, 5);
+ }
+
+ // Truncate if too long
+ if (username.length > 30) {
+ username = username.substring(0, 30);
+ }
+
+ return username;
+}
diff --git a/apps/uload/apps/web/src/lib/utils/reserved-slugs.ts b/apps/uload/apps/web/src/lib/utils/reserved-slugs.ts
new file mode 100644
index 000000000..d172bad8b
--- /dev/null
+++ b/apps/uload/apps/web/src/lib/utils/reserved-slugs.ts
@@ -0,0 +1,684 @@
+/**
+ * Reserved slugs that cannot be used for workspace URLs
+ * to prevent conflicts with system routes, common usernames,
+ * and potential brand confusion
+ */
+export const RESERVED_SLUGS = [
+ // uload specific routes
+ 'uload',
+ 'ulo',
+ 'u',
+ 'w',
+ 'p',
+ 'link',
+ 'links',
+ 'card',
+ 'cards',
+ 'workspace',
+ 'workspaces',
+ 'my',
+ 'analytics',
+ 'stats',
+ 'statistics',
+ 'click',
+ 'clicks',
+ 'qr',
+ 'qrcode',
+ 'preview',
+ 'embed',
+ 'widget',
+ 'share',
+ 'invite',
+ 'invites',
+ 'invitation',
+ 'invitations',
+ 'member',
+ 'members',
+ 'owner',
+ 'owners',
+
+ // System/Admin routes
+ 'admin',
+ 'api',
+ 'www',
+ 'mail',
+ 'support',
+ 'help',
+ 'docs',
+ 'documentation',
+ 'blog',
+ 'legal',
+ 'privacy',
+ 'terms',
+ 'tos',
+ 'contact',
+ 'about',
+ 'pricing',
+ 'prices',
+ 'features',
+ 'security',
+ 'status',
+ 'health',
+ 'ping',
+ 'webhook',
+ 'webhooks',
+ 'callback',
+ 'auth',
+ 'oauth',
+ 'sso',
+ 'login',
+ 'logout',
+ 'register',
+ 'signup',
+ 'signin',
+ 'signout',
+ 'dashboard',
+ 'settings',
+ 'preferences',
+ 'profile',
+ 'account',
+ 'accounts',
+ 'billing',
+ 'subscription',
+ 'subscriptions',
+ 'plan',
+ 'plans',
+ 'upgrade',
+ 'downgrade',
+ 'cancel',
+ 'delete',
+ 'remove',
+ 'edit',
+ 'update',
+ 'create',
+ 'add',
+ 'new',
+ 'manage',
+ 'management',
+ 'admin-panel',
+ 'control-panel',
+ 'cpanel',
+
+ // Common service names
+ 'cdn',
+ 'assets',
+ 'static',
+ 'public',
+ 'images',
+ 'img',
+ 'css',
+ 'js',
+ 'fonts',
+ 'uploads',
+ 'files',
+ 'download',
+ 'downloads',
+ 'archive',
+ 'backup',
+ 'export',
+ 'import',
+ 'sync',
+
+ // Common usernames/brands
+ 'admin',
+ 'administrator',
+ 'root',
+ 'system',
+ 'service',
+ 'bot',
+ 'user',
+ 'users',
+ 'team',
+ 'teams',
+ 'group',
+ 'groups',
+ 'org',
+ 'organization',
+ 'company',
+ 'corp',
+ 'inc',
+ 'llc',
+ 'gmbh',
+ 'ag',
+ 'sa',
+ 'ltd',
+ 'limited',
+
+ // Potential phishing/confusion - Tech Giants
+ 'google',
+ 'facebook',
+ 'meta',
+ 'twitter',
+ 'x',
+ 'instagram',
+ 'threads',
+ 'linkedin',
+ 'github',
+ 'gitlab',
+ 'bitbucket',
+ 'microsoft',
+ 'windows',
+ 'xbox',
+ 'apple',
+ 'iphone',
+ 'ipad',
+ 'mac',
+ 'amazon',
+ 'aws',
+ 'netflix',
+ 'spotify',
+ 'slack',
+ 'discord',
+ 'telegram',
+ 'whatsapp',
+ 'signal',
+ 'zoom',
+ 'teams',
+ 'skype',
+ 'notion',
+ 'trello',
+ 'asana',
+ 'jira',
+ 'confluence',
+ 'atlassian',
+ 'adobe',
+ 'figma',
+ 'canva',
+ 'dropbox',
+ 'box',
+ 'drive',
+ 'onedrive',
+ 'icloud',
+ 'oracle',
+ 'salesforce',
+ 'hubspot',
+ 'mailchimp',
+ 'sendgrid',
+ 'twilio',
+ 'stripe',
+ 'paypal',
+ 'square',
+ 'shopify',
+ 'wix',
+ 'wordpress',
+ 'squarespace',
+ 'godaddy',
+ 'namecheap',
+ 'cloudflare',
+ 'vercel',
+ 'netlify',
+ 'heroku',
+ 'digitalocean',
+ 'linode',
+ 'vultr',
+ 'docker',
+ 'kubernetes',
+ 'redis',
+ 'mongodb',
+ 'postgres',
+ 'mysql',
+ 'firebase',
+ 'supabase',
+ 'openai',
+ 'chatgpt',
+ 'anthropic',
+ 'claude',
+ 'gemini',
+ 'bard',
+ 'copilot',
+ 'midjourney',
+ 'stability',
+ 'huggingface',
+
+ // German companies/brands
+ 'telekom',
+ 'vodafone',
+ 'o2',
+ '1und1',
+ 'bmw',
+ 'mercedes',
+ 'mercedes-benz',
+ 'volkswagen',
+ 'vw',
+ 'audi',
+ 'porsche',
+ 'opel',
+ 'ford',
+ 'tesla',
+ 'siemens',
+ 'sap',
+ 'allianz',
+ 'deutsche-bank',
+ 'deutschebank',
+ 'commerzbank',
+ 'sparkasse',
+ 'volksbank',
+ 'postbank',
+ 'dhl',
+ 'dpd',
+ 'ups',
+ 'fedex',
+ 'hermes',
+ 'lufthansa',
+ 'eurowings',
+ 'ryanair',
+ 'easyjet',
+ 'adidas',
+ 'puma',
+ 'nike',
+ 'reebok',
+ 'bayer',
+ 'basf',
+ 'bosch',
+ 'continental',
+ 'thyssenkrupp',
+ 'henkel',
+ 'beiersdorf',
+ 'nivea',
+ 'hugo-boss',
+ 'hugoboss',
+ 'zalando',
+ 'aboutyou',
+ 'otto',
+ 'lidl',
+ 'aldi',
+ 'edeka',
+ 'rewe',
+ 'penny',
+ 'netto',
+ 'kaufland',
+ 'real',
+ 'metro',
+ 'saturn',
+ 'mediamarkt',
+ 'conrad',
+ 'dm',
+ 'rossmann',
+ 'mueller',
+ 'douglas',
+ 'tchibo',
+ 'ikea',
+ 'hornbach',
+ 'obi',
+ 'bauhaus',
+ 'toom',
+ 'hagebau',
+
+ // Banks & Financial
+ 'visa',
+ 'mastercard',
+ 'amex',
+ 'americanexpress',
+ 'discover',
+ 'bank',
+ 'banking',
+ 'credit',
+ 'debit',
+ 'loan',
+ 'mortgage',
+ 'insurance',
+ 'crypto',
+ 'bitcoin',
+ 'ethereum',
+ 'binance',
+ 'coinbase',
+ 'kraken',
+ 'revolut',
+ 'n26',
+ 'klarna',
+ 'wise',
+ 'transferwise',
+
+ // Social Media & Dating
+ 'youtube',
+ 'tiktok',
+ 'snapchat',
+ 'pinterest',
+ 'reddit',
+ 'tumblr',
+ 'flickr',
+ 'vimeo',
+ 'twitch',
+ 'medium',
+ 'substack',
+ 'patreon',
+ 'onlyfans',
+ 'tinder',
+ 'bumble',
+ 'hinge',
+ 'badoo',
+ 'lovoo',
+ 'parship',
+ 'elitepartner',
+
+ // E-commerce & Marketplaces
+ 'ebay',
+ 'etsy',
+ 'alibaba',
+ 'aliexpress',
+ 'wish',
+ 'shein',
+ 'wayfair',
+ 'booking',
+ 'expedia',
+ 'airbnb',
+ 'uber',
+ 'lyft',
+ 'bolt',
+ 'deliveroo',
+ 'doordash',
+ 'ubereats',
+ 'lieferando',
+ 'wolt',
+ 'gorillas',
+ 'getir',
+ 'flink',
+
+ // News & Media
+ 'nytimes',
+ 'bbc',
+ 'cnn',
+ 'reuters',
+ 'bloomberg',
+ 'forbes',
+ 'wsj',
+ 'guardian',
+ 'spiegel',
+ 'bild',
+ 'zeit',
+ 'faz',
+ 'sueddeutsche',
+ 'stern',
+ 'focus',
+ 'welt',
+ 'handelsblatt',
+ 'tagesschau',
+ 'zdf',
+ 'ard',
+ 'rtl',
+ 'sat1',
+ 'prosieben',
+
+ // Gaming & Entertainment
+ 'steam',
+ 'epic',
+ 'epicgames',
+ 'ubisoft',
+ 'ea',
+ 'electronicarts',
+ 'activision',
+ 'blizzard',
+ 'riot',
+ 'riotgames',
+ 'valve',
+ 'nintendo',
+ 'playstation',
+ 'sony',
+ 'xbox',
+ 'minecraft',
+ 'fortnite',
+ 'roblox',
+ 'pubg',
+ 'gta',
+ 'cod',
+ 'lol',
+ 'leagueoflegends',
+ 'valorant',
+ 'overwatch',
+ 'warcraft',
+
+ // Automotive & Transportation
+ 'uber',
+ 'lyft',
+ 'grab',
+ 'didi',
+ 'bolt',
+ 'freenow',
+ 'mytaxi',
+ 'blablacar',
+ 'flixbus',
+ 'flixmobility',
+ 'db',
+ 'deutschebahn',
+ 'bahn',
+ 'ice',
+ 'sbahn',
+ 'ubahn',
+ 'share-now',
+ 'car2go',
+ 'sixt',
+ 'europcar',
+ 'hertz',
+ 'avis',
+ 'enterprise',
+
+ // Food & Beverage Brands
+ 'mcdonalds',
+ 'burgerking',
+ 'kfc',
+ 'subway',
+ 'starbucks',
+ 'dunkin',
+ 'dominos',
+ 'pizzahut',
+ 'papajohns',
+ 'coca-cola',
+ 'cocacola',
+ 'pepsi',
+ 'redbull',
+ 'monster',
+ 'nestle',
+ 'danone',
+ 'unilever',
+ 'kelloggs',
+ 'heinz',
+ 'nutella',
+ 'ferrero',
+ 'haribo',
+ 'ritter-sport',
+ 'rittersport',
+ 'milka',
+ 'lindt',
+
+ // Telecom & ISPs
+ 'att',
+ 'verizon',
+ 'tmobile',
+ 't-mobile',
+ 'orange',
+ 'bt',
+ 'sky',
+ 'virgin',
+ 'comcast',
+ 'spectrum',
+ 'cox',
+ 'unitymedia',
+ 'kabel-deutschland',
+ 'kabeldeutschland',
+ 'pyur',
+ 'netcologne',
+ 'mnet',
+
+ // Universities & Education
+ 'harvard',
+ 'stanford',
+ 'mit',
+ 'oxford',
+ 'cambridge',
+ 'yale',
+ 'princeton',
+ 'coursera',
+ 'udemy',
+ 'udacity',
+ 'edx',
+ 'khan',
+ 'khanacademy',
+ 'duolingo',
+ 'babbel',
+ 'rosetta',
+ 'rosettastone',
+
+ // Healthcare & Pharma
+ 'pfizer',
+ 'moderna',
+ 'johnson',
+ 'jnj',
+ 'novartis',
+ 'roche',
+ 'merck',
+ 'abbott',
+ 'medtronic',
+ 'cvs',
+ 'walgreens',
+ 'doctolib',
+ 'jameda',
+ 'aponeo',
+ 'docmorris',
+ 'shop-apotheke',
+ 'shopapotheke',
+
+ // Short/valuable names
+ 'a',
+ 'b',
+ 'c',
+ 'd',
+ 'e',
+ 'f',
+ 'g',
+ 'h',
+ 'i',
+ 'j',
+ 'k',
+ 'l',
+ 'm',
+ 'n',
+ 'o',
+ 'p',
+ 'q',
+ 'r',
+ 's',
+ 't',
+ 'u',
+ 'v',
+ 'w',
+ 'x',
+ 'y',
+ 'z',
+ 'ai',
+ 'ml',
+ 'io',
+ 'app',
+ 'web',
+ 'dev',
+ 'pro',
+ 'premium',
+ 'plus',
+ 'max',
+ 'ultra',
+ 'super',
+ 'mega',
+ 'nano',
+ 'micro',
+ 'mini',
+ 'test',
+ 'demo',
+ 'example',
+ 'sample',
+ 'temp',
+ 'tmp',
+ 'new',
+ 'old',
+ 'beta',
+ 'alpha',
+ 'v1',
+ 'v2',
+ 'v3',
+ 'latest',
+ 'stable',
+ 'main',
+ 'master',
+ 'default',
+ 'null',
+ 'undefined',
+ 'true',
+ 'false',
+ 'yes',
+ 'no',
+ 'on',
+ 'off',
+ 'all',
+ 'none',
+ 'one',
+ 'two',
+ 'three',
+ 'free',
+ 'trial',
+ 'sandbox',
+ 'staging',
+ 'production',
+ 'localhost',
+ 'www',
+ 'ftp',
+ 'sftp',
+ 'ssh',
+ 'http',
+ 'https',
+ 'ssl',
+ 'tls',
+ 'dns',
+ 'mx',
+ 'ns',
+ 'cname',
+ 'txt',
+ 'spf',
+ 'dkim',
+ 'dmarc',
+] as const;
+
+/**
+ * Check if a slug is reserved
+ */
+export function isSlugReserved(slug: string): boolean {
+ return RESERVED_SLUGS.includes(slug.toLowerCase() as any);
+}
+
+/**
+ * Validate a slug for workspace creation
+ * Returns an error message if invalid, null if valid
+ */
+export function validateWorkspaceSlug(slug: string): string | null {
+ if (!slug) {
+ return null; // Empty slug is allowed (auto-generated)
+ }
+
+ // Check format
+ if (!/^[a-z0-9\-]+$/.test(slug)) {
+ return 'Workspace URL can only contain lowercase letters, numbers, and hyphens';
+ }
+
+ // Check length
+ if (slug.length < 2) {
+ return 'Workspace URL must be at least 2 characters long';
+ }
+
+ if (slug.length > 50) {
+ return 'Workspace URL cannot be longer than 50 characters';
+ }
+
+ // Check reserved
+ if (isSlugReserved(slug)) {
+ return 'This workspace URL is reserved and cannot be used';
+ }
+
+ // Check start/end with hyphen
+ if (slug.startsWith('-') || slug.endsWith('-')) {
+ return 'Workspace URL cannot start or end with a hyphen';
+ }
+
+ // Check consecutive hyphens
+ if (slug.includes('--')) {
+ return 'Workspace URL cannot contain consecutive hyphens';
+ }
+
+ return null; // Valid
+}
diff --git a/apps/uload/apps/web/src/paraglide/messages.ts b/apps/uload/apps/web/src/paraglide/messages.ts
new file mode 100644
index 000000000..4baa6e5e7
--- /dev/null
+++ b/apps/uload/apps/web/src/paraglide/messages.ts
@@ -0,0 +1,175 @@
+// Compatibility layer: Paraglide-style API using svelte-i18n
+// This allows existing code using m.key() to work with svelte-i18n
+import { _, locale } from 'svelte-i18n';
+import { get } from 'svelte/store';
+import '$lib/i18n'; // Initialize i18n
+
+// Create a Proxy that returns translation functions for any key
+const messageProxy = new Proxy(
+ {},
+ {
+ get(_target, prop: string) {
+ // Return a function that gets the translation
+ return () => {
+ const translate = get(_);
+ return translate(prop);
+ };
+ },
+ }
+) as Record string>;
+
+// Export everything from the proxy
+export const {
+ // Navigation
+ nav_login,
+ nav_register,
+ nav_dashboard,
+ nav_folders,
+ nav_profile,
+ nav_logout,
+ nav_pricing,
+
+ // Home
+ home_title,
+ home_subtitle,
+ home_url_label_qr,
+ home_url_label,
+ home_title_label,
+ home_title_placeholder,
+ home_description_label,
+ home_description_placeholder,
+ home_expires_label,
+ home_expires_placeholder,
+ home_max_clicks_label,
+ home_max_clicks_placeholder,
+ home_password_label,
+ home_password_placeholder,
+ home_guest_info,
+ home_guest_signin_hint,
+ home_processing,
+ home_submit_button_qr,
+ home_submit_button,
+
+ // Auth
+ auth_modal_signin,
+ auth_sign_in,
+ auth_login_button,
+ auth_login_button_loading,
+ auth_register_button,
+ auth_register_button_loading,
+ auth_email_label,
+ auth_email_placeholder,
+ auth_email_address_label,
+ auth_password_label,
+ auth_password_confirm_label,
+ auth_forgot_password,
+ auth_no_account,
+ auth_have_account,
+ auth_create_account,
+ auth_create_account_title,
+ auth_create_account_subtitle,
+ auth_welcome_back,
+ auth_welcome_back_subtitle,
+ auth_back_to_login,
+ auth_go_to_login,
+ auth_remember_password,
+ auth_username_auto,
+ auth_registration_tip,
+ auth_registration_success,
+ auth_registration_success_message,
+ auth_reset_password_title,
+ auth_reset_password_subtitle,
+ auth_reset_password_button,
+ auth_reset_password_button_loading,
+ auth_send_reset_button,
+ auth_send_reset_button_loading,
+ auth_reset_email_sent_title,
+ auth_reset_email_sent_message,
+ auth_request_new_reset_link,
+ auth_set_new_password_title,
+ auth_set_new_password_subtitle,
+ auth_new_password_label,
+ auth_new_password_placeholder,
+ auth_confirm_new_password_label,
+ auth_confirm_new_password_placeholder,
+ auth_password_reset_success,
+ auth_password_reset_success_message,
+ auth_invalid_reset_link,
+ auth_invalid_reset_link_message,
+ auth_invalid_verification_link,
+ auth_invalid_verification_link_message,
+ auth_verification_link_expired,
+ auth_verification_link_expired_message,
+ auth_email_verified,
+ auth_email_verified_message,
+ auth_email_already_verified,
+ auth_email_already_verified_message,
+ auth_email_already_verified_notify,
+ auth_email_already_verified_notify_desc,
+ auth_token_expired_notify,
+ auth_token_expired_notify_desc,
+ auth_add_account,
+ auth_add_account_info,
+ auth_add_account_subtitle,
+ auth_add_account_switch_info,
+
+ // Account
+ account_my_account,
+ account_add_account,
+ account_team_accounts,
+ account_no_team_accounts,
+ account_team_invite_info,
+ account_team_member,
+
+ // Workspace
+ workspace_switch,
+ workspace_personal,
+ workspace_create,
+
+ // Hero
+ hero_control_headline,
+ hero_control_subheadline,
+ hero_control_cta,
+ hero_free_text,
+ hero_trust_badge_,
+ hero_a,
+ hero_b,
+ hero_c,
+
+ // Toast
+ toast_login_success,
+ toast_login_error,
+ toast_logout_success,
+ toast_register_success,
+ toast_link_created,
+ toast_link_updated,
+ toast_link_deleted,
+ toast_link_copied,
+ toast_profile_updated,
+ toast_avatar_uploaded,
+ toast_password_changed,
+ toast_password_reset_sent,
+ toast_email_verified,
+ toast_session_expired,
+ toast_session_expired_desc,
+ toast_network_error,
+ toast_network_error_desc,
+ toast_permission_denied,
+ toast_payment_failed,
+ toast_payment_failed_desc,
+ toast_subscription_upgraded,
+ toast_subscription_cancelled,
+ toast_unsupported_format,
+
+ // Errors
+ error_link_creation,
+ error_link_creation_single,
+ error_password_change,
+ error_save,
+} = messageProxy;
+
+// Re-export locale utilities
+export { locale };
+
+// Default export for `import * as m from`
+export default messageProxy;
diff --git a/apps/uload/apps/web/src/routes/(app)/+layout.svelte b/apps/uload/apps/web/src/routes/(app)/+layout.svelte
new file mode 100644
index 000000000..95bbf3d55
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/+layout.svelte
@@ -0,0 +1,127 @@
+
+
+
+
+{#if loading}
+
+{:else}
+
+
+ {#snippet logo()}
+ 🔗
+ uload
+ {/snippet}
+
+
+
+
+ {@render children?.()}
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/routes/(app)/apps/+page.svelte b/apps/uload/apps/web/src/routes/(app)/apps/+page.svelte
new file mode 100644
index 000000000..2a8074e34
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/apps/+page.svelte
@@ -0,0 +1,17 @@
+
+
+
+ Alle Apps - uload
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte b/apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte
new file mode 100644
index 000000000..c191708f7
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/feedback/+page.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/my/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/+page.svelte
new file mode 100644
index 000000000..199d12cbc
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/+page.svelte
@@ -0,0 +1 @@
+
diff --git a/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte
new file mode 100644
index 000000000..d68466313
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/analytics/[id]/+page.svelte
@@ -0,0 +1,341 @@
+
+
+
+
+
+
+
+
+
+
Short URL
+
{formatUrl(data.link.short_code)}
+
+
+
Original URL
+
{data.link.original_url}
+
+
+
+
+
+
Total Clicks
+
{data.totalClicks}
+
+
+
Status
+
+ {#if data.link.is_active}
+ Active
+ {:else}
+ Inactive
+ {/if}
+
+
+
+
Created
+
{new Date(data.link.created).toLocaleDateString()}
+
+
+
Features
+
+ {#if data.link.password}
+ 🔒 Protected
+ {/if}
+ {#if data.link.expires_at}
+ ⏰ Expires
+ {/if}
+ {#if data.link.max_clicks}
+ 🔢 Limited
+ {/if}
+
+
+
+
+
+
+
+
QR Code
+ (showQRCode = !showQRCode)}
+ class="text-sm text-purple-600 hover:text-purple-800"
+ >
+ {showQRCode ? 'Hide' : 'Show'} QR Code
+
+
+ {#if showQRCode}
+
+
+
+
+
+
QR Code Color
+
+ (qrColor = 'black')}
+ class="h-10 w-10 rounded border-2 bg-black {qrColor === 'black'
+ ? 'border-blue-500 ring-2 ring-blue-200'
+ : 'border-gray-300'}"
+ aria-label="Black color"
+ >
+ (qrColor = 'white')}
+ class="h-10 w-10 rounded border-2 bg-white {qrColor === 'white'
+ ? 'border-blue-500 ring-2 ring-blue-200'
+ : 'border-gray-300'}"
+ aria-label="White color"
+ >
+ (qrColor = 'gold')}
+ class="h-10 w-10 rounded border-2 {qrColor === 'gold'
+ ? 'border-blue-500 ring-2 ring-blue-200'
+ : 'border-gray-300'}"
+ style="background: #f8d62b"
+ aria-label="Gold color"
+ >
+
+
+
+
+ Download Format
+
+ PNG (High Quality)
+ SVG (Vector)
+ JPG (Compressed)
+
+
+
+
+
+ downloadQR()}
+ class="rounded-md bg-purple-600 px-6 py-2 text-white transition duration-200 hover:bg-purple-700"
+ >
+ Download as {qrFormat.toUpperCase()}
+
+ navigator.clipboard.writeText(formatUrl(data.link.short_code))}
+ class="rounded-md bg-gray-600 px-6 py-2 text-white transition duration-200 hover:bg-gray-700"
+ >
+ Copy URL
+
+
+
+
+ Scan this QR code to access the short link directly
+
+
+ {/if}
+
+
+
+
+
Browser Distribution
+ {#if data.browserStats.length > 0}
+
+ {#each data.browserStats as [browser, count]}
+
+ {/each}
+
+ {:else}
+
No data yet
+ {/if}
+
+
+
+
Device Types
+ {#if data.deviceStats.length > 0}
+
+ {#each data.deviceStats as [device, count]}
+
+ {/each}
+
+ {:else}
+
No data yet
+ {/if}
+
+
+
+
+
Top Referrers
+ {#if data.refererStats.length > 0}
+
+ {#each data.refererStats as [referrer, count]}
+
+ {referrer}
+ {count} clicks
+
+ {/each}
+
+ {:else}
+
No referrer data yet
+ {/if}
+
+
+
+
Clicks by Day
+ {#if data.clicksByDay.length > 0}
+
+
+ {#each data.clicksByDay as [day, count]}
+
+ {/each}
+
+
+ {:else}
+
No daily data yet
+ {/if}
+
+
+
+
Recent Clicks
+ {#if data.recentClicks.length > 0}
+
+
+
+
+ Time
+ Browser
+ Device
+ Referrer
+
+
+
+ {#each data.recentClicks as click}
+
+
+ {new Date(click.created).toLocaleString()}
+
+ {getBrowserFromUserAgent(click.user_agent) || 'Unknown'}
+ {click.device || 'Unknown'}
+
+ {#if click.referer}
+ {@const url = new URL(click.referer)}
+ {url.hostname}
+ {:else}
+ Direct
+ {/if}
+
+
+ {/each}
+
+
+
+ {:else}
+
No clicks yet
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte
new file mode 100644
index 000000000..e9ae78d13
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte
@@ -0,0 +1,403 @@
+
+
+
+
+
+
Profile Cards
+
+ (showStats = !showStats)}
+ class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
+ title="Toggle Stats"
+ >
+ Stats
+
+ createNewCard()}
+ class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
+ >
+ + New Card
+
+
+
+
+
+ {#if showStats}
+
+
+
{userCards?.length || 0}
+
Total Cards
+
+ {userCards?.filter((c) => c.page === 'profile').length || 0} on profile
+
+
+
+
+ {userCards?.filter((c) => c.metadata?.is_active !== false).length || 0}
+
+
Active Cards
+
+
+
+ {/if}
+
+ {#if loading}
+
+ {:else if userCards.length > 0}
+
+
Your Profile Cards
+
+ Drag to reorder. Cards will appear in this order on your profile.
+
+
+
+ {#each userCards as card, index}
+
handleDragStart(e, index)}
+ ondragover={(e) => handleDragOver(e, index)}
+ ondragleave={handleDragLeave}
+ ondrop={(e) => handleDrop(e, index)}
+ ondragend={handleDragEnd}
+ >
+
+
+
+
+
+
+
+
+
+ {#if card.page === 'profile'}
+
+ On Profile
+
+ {:else}
+
+ Not on Profile
+
+ {/if}
+
+ {#if card.metadata?.is_active === false}
+
+ Hidden
+
+ {/if}
+
+
+
+
+ editCard(card)}
+ class="text-sm text-theme-primary hover:underline"
+ >
+ Edit
+
+ duplicateCard(card)}
+ class="text-sm text-theme-primary hover:underline"
+ >
+ Duplicate
+
+ toggleProfileDisplay(card)}
+ class="text-sm text-theme-primary hover:underline"
+ >
+ {card.page === 'profile' ? 'Remove from Profile' : 'Add to Profile'}
+
+ toggleCardVisibility(card)}
+ class="text-sm text-theme-primary hover:underline"
+ >
+ {card.metadata?.is_active === false ? 'Show' : 'Hide'}
+
+ {
+ cardToDelete = card.id || null;
+ showDeleteConfirm = true;
+ }}
+ class="text-sm text-red-600 hover:underline"
+ >
+ Delete
+
+
+
+
+ {/each}
+
+
+ {:else}
+
+
No cards yet
+
Create your first card to get started
+
createNewCard()}
+ class="inline-flex items-center rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
+ >
+ Create Your First Card
+
+
+ {/if}
+
+
+
+
+{#if showDeleteConfirm && cardToDelete}
+
+
+
Delete Card
+
+ Are you sure you want to delete this card? This action cannot be undone.
+
+
+ {
+ showDeleteConfirm = false;
+ cardToDelete = null;
+ }}
+ class="rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text hover:bg-theme-border"
+ >
+ Cancel
+
+ cardToDelete && deleteCard(cardToDelete)}
+ class="rounded-lg bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700"
+ >
+ Delete Card
+
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup b/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup
new file mode 100644
index 000000000..d5ad4e0e8
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/cards/+page.svelte.backup
@@ -0,0 +1,482 @@
+
+
+
+
+
+
Profile Cards
+
+
showStats = !showStats}
+ class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
+ title="Toggle Stats"
+ aria-label="Toggle Statistics"
+ >
+
+
+
+
+
showProfileAppearance = !showProfileAppearance}
+ class="rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
+ title="Profile Appearance"
+ aria-label="Toggle Profile Appearance"
+ >
+
+
+
+
+
createNewCard()}
+ class="rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
+ >
+ + New Card
+
+
+
+
+
+ {#if showStats}
+
+
+
+
+
{userCards?.length || 0}
+
Total Cards
+
{userCards?.filter(c => c.page === 'profile').length || 0} on profile
+
+
+
+
+
+
+
+
+
+
+
{userCards?.filter(c => c.metadata?.isActive !== false).length || 0}
+
Active Cards
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+ {#if showProfileAppearance}
+
+
Profile Appearance
+
+
+ Profile Background
+
+ {
+ const color = e.currentTarget.value;
+ try {
+ const response = await fetch('/settings?/updateProfile', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ profileBackground: color,
+ name: data.user?.name || '',
+ email: data.user?.email || '',
+ bio: data.user?.bio || '',
+ location: data.user?.location || '',
+ website: data.user?.website || '',
+ github: data.user?.github || '',
+ twitter: data.user?.twitter || '',
+ linkedin: data.user?.linkedin || '',
+ instagram: data.user?.instagram || ''
+ })
+ });
+ if (response.ok) {
+ // Update local state
+ if (data.user) {
+ data.user.profileBackground = color;
+ }
+ }
+ } catch (error) {
+ console.error('Failed to update profile background:', error);
+ }
+ }}
+ class="h-10 w-20 cursor-pointer rounded border border-theme-border"
+ />
+ {
+ const input = document.getElementById('profileBackground') as HTMLInputElement;
+ if (input && e.currentTarget.value) {
+ input.value = e.currentTarget.value;
+ input.dispatchEvent(new Event('change'));
+ }
+ }}
+ class="rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text"
+ >
+ Custom Color
+ Light Gray (Default)
+ Light Blue
+ Light Green
+ Light Yellow
+ Light Pink
+ Light Purple
+ Dark Gray
+ Dark Blue
+ Black
+
+
+ Choose a color for your profile page background
+
+
+
+ {/if}
+
+
+ {#if loading}
+
+ {:else if userCards.length > 0}
+
+
Your Profile Cards
+
+ Drag to reorder. Cards will appear in this order on your profile.
+
+
+
+ {#each userCards as card, index}
+
{
+ cardToDelete = cardId;
+ showDeleteConfirm = true;
+ }}
+ />
+ {/each}
+
+
+ {:else}
+
+
+
+
+
No cards on your profile yet
+
+ Create cards with our visual drag-and-drop builder
+
+
createNewCard()}
+ class="inline-flex items-center rounded-lg bg-theme-primary px-4 py-2 font-medium text-white shadow-lg transition-colors hover:bg-theme-primary-hover"
+ >
+ Create Your First Card
+
+
+ {/if}
+
+
+
+
+{#if showDeleteConfirm && cardToDelete}
+
+
+
Delete Card
+
+ Are you sure you want to delete this card? This action cannot be undone.
+
+
+ {
+ showDeleteConfirm = false;
+ cardToDelete = null;
+ }}
+ class="rounded-lg bg-theme-surface-hover px-4 py-2 font-medium text-theme-text hover:bg-theme-border"
+ >
+ Cancel
+
+ cardToDelete && removeCard(cardToDelete)}
+ class="rounded-lg bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700"
+ >
+ Delete Card
+
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte
new file mode 100644
index 000000000..0236961c8
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/cards/builder/+page.svelte
@@ -0,0 +1,676 @@
+
+
+
+ Card Builder - uload
+
+
+
+ {#if loading}
+
+ {:else}
+
+
+
+
+ {editingCard ? 'Card bearbeiten' : 'Neue Card'}
+
+
+ goto('/my/cards')}
+ class="rounded-lg border border-theme-border bg-theme-surface px-4 py-2 font-medium text-theme-text transition-all hover:bg-theme-surface-hover"
+ >
+ Abbrechen
+
+
+ {saving ? 'Speichern...' : editingCard ? 'Änderungen speichern' : 'Card erstellen'}
+
+
+
+
+
+
+
+
+
+
+ {#if headerModule}
+
+
+
+ {#if headerModule.props.avatar || userAvatarUrl}
+
+ {:else}
+
+
+ {(headerModule.props.title || 'U')[0].toUpperCase()}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if editingTitle}
+
e.key === 'Enter' && saveTitle()}
+ class="mb-2 w-full rounded-lg border-2 border-theme-primary bg-theme-background px-3 py-1 text-center text-2xl font-bold text-theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
+ autofocus
+ />
+ {:else}
+
+ {headerModule.props.title || 'Klicke zum Bearbeiten'}
+
+ {/if}
+
+
+ {#if editingSubtitle}
+
e.key === 'Enter' && saveSubtitle()}
+ class="w-full rounded-lg border-2 border-theme-primary bg-theme-background px-3 py-1 text-center text-theme-text-muted focus:outline-none focus:ring-2 focus:ring-theme-accent"
+ autofocus
+ />
+ {:else}
+
+ {headerModule.props.subtitle || 'Position hinzufügen'}
+
+ {/if}
+
+ {/if}
+
+
+ {#key card.config.modules}
+ {@const currentLinksModule = card.config.modules?.find((m) => m.type === 'links')}
+
+
+
Deine Links
+ (showLinkSelector = !showLinkSelector)}
+ class="rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-theme-primary-hover"
+ >
+ {showLinkSelector ? '✓ Fertig' : '+ Links hinzufügen'}
+
+
+
+ {#if showLinkSelector}
+
+
+ {#if loadingLinks}
+
+ ⚡ Lade deine Links...
+
+ {:else if userLinks.length === 0}
+
+
Du hast noch keine Links erstellt.
+
+
+
+
+ Ersten Link erstellen
+
+
+ {:else}
+
+
+ Wähle die Links aus, die auf deiner Card erscheinen sollen:
+
+ {#each userLinks as link}
+
+ toggleLinkSelection(link.id)}
+ class="h-5 w-5 rounded border-2 text-theme-primary focus:ring-2 focus:ring-theme-accent"
+ />
+ {link.icon}
+
+
{link.title}
+
ulo.ad/l/{link.shortCode}
+
+
+ {/each}
+
+ {/if}
+
+ {:else}
+
+ {#if currentLinksModule?.props?.links && currentLinksModule.props.links.length > 0}
+
+ {:else}
+
+
+
+
+
Noch keine Links hinzugefügt
+
+ Klicke auf "Links hinzufügen" um deine uload Links auszuwählen
+
+
+ {/if}
+ {/if}
+
+ {/key}
+
+
+
+
+
+
+ Card Name
+
+
+
+ Sichtbarkeit
+
+ 🌍 Öffentlich
+ 🔗 Nicht gelistet
+ 🔒 Privat
+
+
+
+
+
+
+
+ {/if}
+
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
new file mode 100644
index 000000000..5dd4e3cac
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/links/+page.svelte
@@ -0,0 +1,286 @@
+
+
+
+
+
+
+
+ Links
+ {#if filteredLinks.length > 0}
+ ({filteredLinks.length})
+ {/if}
+
+ (showCreateForm = !showCreateForm)}
+ class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-indigo-700"
+ >
+ {showCreateForm ? '- Ausblenden' : '+ Neuer Link'}
+
+
+
+
+ {#if showCreateForm}
+
+
+
+
+ Link erstellen
+
+
+
+ {/if}
+
+
+
+
+
+ Alle
+ Aktiv
+ Inaktiv
+
+ {#if folders.value && folders.value.length > 0}
+
+ Alle Ordner
+ {#each folders.value as folder}
+ {folder.name}
+ {/each}
+
+ {/if}
+
+
+
+ {#if links.loading}
+
+ {#each Array(3) as _}
+
+ {/each}
+
+ {:else if filteredLinks.length === 0}
+
+
Noch keine Links
+
Erstelle deinen ersten gekürzten Link!
+
(showCreateForm = true)}
+ class="mt-4 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
+ >
+ + Neuer Link
+
+
+ {:else}
+
+ {#each filteredLinks as link (link.id)}
+
+
+
+
+
{link.title || link.shortCode}
+
+ /{link.shortCode}
+
+
+
{link.originalUrl}
+
+
+
+
+ {link.clickCount} clicks
+
+
copyShortUrl(link.shortCode)}
+ class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
+ title="Link kopieren"
+ >
+
+
+
+
+
toggleActive(link)}
+ class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
+ title={link.isActive ? 'Deaktivieren' : 'Aktivieren'}
+ >
+
+
+
+
+
deleteLink(link)}
+ class="rounded-lg p-2 opacity-0 transition-all hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
+ title="Löschen"
+ >
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte
new file mode 100644
index 000000000..fed3029ef
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/links/debug/+page.svelte
@@ -0,0 +1,96 @@
+
+
+
+
+
+ PocketBase Debug Information
+
+
+ {#if loading}
+
+
Loading debug information...
+
+ {:else if error}
+
+ {:else if debugData}
+
+
+
+
User Information
+
+{JSON.stringify(debugData.user, null, 2)}
+
+
+
+
+
+ PocketBase Connection
+
+
+{JSON.stringify(debugData.pb, null, 2)}
+
+
+
+
+
Test Results
+ {#each Object.entries(debugData.tests) as [testName, result]}
+
+
+ {testName}: {result.success ? '✅ Success' : '❌ Failed'}
+
+
+{JSON.stringify(result, null, 2)}
+
+ {/each}
+
+
+
+
+ Raw Debug Data
+
+{JSON.stringify(debugData, null, 2)}
+
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/my/tags/+page.svelte b/apps/uload/apps/web/src/routes/(app)/my/tags/+page.svelte
new file mode 100644
index 000000000..9e6a49470
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/tags/+page.svelte
@@ -0,0 +1,188 @@
+
+
+
+
+
Tags
+ (showCreateForm = !showCreateForm)}
+ class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-700"
+ >
+ {showCreateForm ? '- Ausblenden' : '+ Neuer Tag'}
+
+
+
+ {#if showCreateForm}
+
+
+
+ Name
+ e.key === 'Enter' && createTag()}
+ />
+
+
+ Farbe
+
+
+
+ Erstellen
+
+
+
+ {/if}
+
+ {#if tags.loading}
+
+ {#each Array(3) as _}
+
+ {/each}
+
+ {:else if !tags.value || tags.value.length === 0}
+
+
Noch keine Tags
+
Erstelle Tags um deine Links zu organisieren.
+
+ {:else}
+
+ {#each tags.value as tag (tag.id)}
+
+ {#if editingTag?.id === tag.id}
+
+ {:else}
+
+
+
+ {tag.name}
+
+
+
{getUsageCount(tag.id)} Links
+
(editingTag = { ...tag })}
+ class="rounded p-1 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
+ >
+
+
+
+
+
deleteTag(tag)}
+ class="rounded p-1 opacity-0 transition-all hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
+ >
+
+
+
+
+
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts b/apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts
new file mode 100644
index 000000000..a0036f070
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/my/tags/page.server.spec.ts
@@ -0,0 +1,425 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fail } from '@sveltejs/kit';
+import * as actions from './+page.server';
+import { pb, generateTagSlug, DEFAULT_TAG_COLORS } from '$lib/pocketbase';
+import { createTestTag, createTestUser } from '$tests/factories';
+
+// Mock @sveltejs/kit
+vi.mock('@sveltejs/kit', () => ({
+ fail: vi.fn((status, data) => ({ status, data })),
+}));
+
+// Mock PocketBase
+vi.mock('$lib/pocketbase', () => ({
+ pb: {
+ collection: vi.fn(),
+ },
+ generateTagSlug: vi.fn((name) => name.toLowerCase().replace(/\s+/g, '-')),
+ DEFAULT_TAG_COLORS: ['#3B82F6', '#EF4444', '#10B981'],
+}));
+
+describe('Tags Page Server Actions', () => {
+ let mockCollection: any;
+ let testUser: any;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ testUser = createTestUser({
+ id: 'user123',
+ email: 'test@example.com',
+ });
+
+ // Setup mock collection methods
+ mockCollection = {
+ getList: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ };
+
+ (pb.collection as any).mockReturnValue(mockCollection);
+ });
+
+ describe('load function', () => {
+ it('should load tags for authenticated user', async () => {
+ const mockTags = [
+ createTestTag({ id: 'tag1', name: 'Work', user_id: 'user123' }),
+ createTestTag({ id: 'tag2', name: 'Personal', user_id: 'user123' }),
+ ];
+
+ mockCollection.getList
+ .mockResolvedValueOnce({
+ items: mockTags,
+ totalItems: 2,
+ })
+ .mockResolvedValue({
+ items: [],
+ totalItems: 0,
+ });
+
+ const result = await actions.load({
+ locals: { user: testUser },
+ } as any);
+
+ expect(mockCollection.getList).toHaveBeenCalledWith(1, 100, {
+ filter: `user_id="user123"`,
+ sort: '-usage_count,name',
+ });
+
+ expect(result.tags).toHaveLength(2);
+ expect(result.tags[0]).toHaveProperty('linkCount', 0);
+ });
+
+ it('should return empty array on error', async () => {
+ mockCollection.getList.mockRejectedValue(new Error('Database error'));
+
+ const result = await actions.load({
+ locals: { user: testUser },
+ } as any);
+
+ expect(result.tags).toEqual([]);
+ });
+
+ it('should include link counts for each tag', async () => {
+ const mockTag = createTestTag({ id: 'tag1', name: 'Work', user_id: 'user123' });
+
+ mockCollection.getList
+ .mockResolvedValueOnce({
+ items: [mockTag],
+ totalItems: 1,
+ })
+ .mockResolvedValueOnce({
+ items: [],
+ totalItems: 5, // 5 links using this tag
+ });
+
+ const result = await actions.load({
+ locals: { user: testUser },
+ } as any);
+
+ expect(result.tags[0].linkCount).toBe(5);
+ });
+ });
+
+ describe('create action', () => {
+ it('should create a new tag successfully', async () => {
+ const formData = new FormData();
+ formData.append('name', 'New Tag');
+ formData.append('color', '#3B82F6');
+ formData.append('icon', '🏷️');
+ formData.append('is_public', 'on');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ const expectedTag = {
+ id: 'new-tag-id',
+ name: 'New Tag',
+ slug: 'new-tag',
+ color: '#3B82F6',
+ icon: '🏷️',
+ user_id: 'user123',
+ is_public: true,
+ usage_count: 0,
+ };
+
+ mockCollection.create.mockResolvedValue(expectedTag);
+
+ const result = await actions.actions.create({
+ request: mockRequest,
+ locals: { user: testUser },
+ } as any);
+
+ expect(mockCollection.create).toHaveBeenCalledWith({
+ name: 'New Tag',
+ slug: 'new-tag',
+ color: '#3B82F6',
+ icon: '🏷️',
+ user_id: 'user123',
+ is_public: true,
+ usage_count: 0,
+ });
+
+ expect(result).toEqual({ success: true, tag: expectedTag });
+ });
+
+ it('should trim tag name', async () => {
+ const formData = new FormData();
+ formData.append('name', ' Trimmed Tag ');
+ formData.append('color', '#3B82F6');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.create.mockResolvedValue({ id: 'tag-id' });
+
+ await actions.actions.create({
+ request: mockRequest,
+ locals: { user: testUser },
+ } as any);
+
+ expect(mockCollection.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'Trimmed Tag',
+ slug: 'trimmed-tag',
+ })
+ );
+ });
+
+ it('should use default color if not provided', async () => {
+ const formData = new FormData();
+ formData.append('name', 'Tag');
+ formData.append('color', '');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.create.mockResolvedValue({ id: 'tag-id' });
+
+ await actions.actions.create({
+ request: mockRequest,
+ locals: { user: testUser },
+ } as any);
+
+ expect(mockCollection.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ color: DEFAULT_TAG_COLORS[0],
+ })
+ );
+ });
+
+ it('should handle is_public correctly', async () => {
+ const formData = new FormData();
+ formData.append('name', 'Private Tag');
+ // is_public not set (checkbox unchecked)
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.create.mockResolvedValue({ id: 'tag-id' });
+
+ await actions.actions.create({
+ request: mockRequest,
+ locals: { user: testUser },
+ } as any);
+
+ expect(mockCollection.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ is_public: false,
+ })
+ );
+ });
+
+ it('should fail if name is not provided', async () => {
+ const formData = new FormData();
+ formData.append('name', '');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ const result = await actions.actions.create({
+ request: mockRequest,
+ locals: { user: testUser },
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Tag name is required' });
+ expect(mockCollection.create).not.toHaveBeenCalled();
+ });
+
+ it('should handle database errors', async () => {
+ const formData = new FormData();
+ formData.append('name', 'Test Tag');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.create.mockRejectedValue(new Error('Database error'));
+
+ const result = await actions.actions.create({
+ request: mockRequest,
+ locals: { user: testUser },
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to create tag' });
+ });
+ });
+
+ describe('update action', () => {
+ it('should update tag successfully', async () => {
+ const formData = new FormData();
+ formData.append('id', 'tag123');
+ formData.append('name', 'Updated Tag');
+ formData.append('color', '#EF4444');
+ formData.append('icon', '⭐');
+ formData.append('is_public', 'on');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.update.mockResolvedValue({ id: 'tag123' });
+
+ const result = await actions.actions.update({
+ request: mockRequest,
+ } as any);
+
+ expect(mockCollection.update).toHaveBeenCalledWith('tag123', {
+ name: 'Updated Tag',
+ slug: 'updated-tag',
+ color: '#EF4444',
+ icon: '⭐',
+ is_public: true,
+ });
+
+ expect(result).toEqual({ updated: true });
+ });
+
+ it('should fail if id is not provided', async () => {
+ const formData = new FormData();
+ formData.append('name', 'Tag');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ const result = await actions.actions.update({
+ request: mockRequest,
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID and name are required' });
+ expect(mockCollection.update).not.toHaveBeenCalled();
+ });
+
+ it('should fail if name is not provided', async () => {
+ const formData = new FormData();
+ formData.append('id', 'tag123');
+ formData.append('name', '');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ const result = await actions.actions.update({
+ request: mockRequest,
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID and name are required' });
+ });
+
+ it('should handle database errors', async () => {
+ const formData = new FormData();
+ formData.append('id', 'tag123');
+ formData.append('name', 'Tag');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.update.mockRejectedValue(new Error('Database error'));
+
+ const result = await actions.actions.update({
+ request: mockRequest,
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to update tag' });
+ });
+ });
+
+ describe('delete action', () => {
+ it('should delete tag and its relationships', async () => {
+ const formData = new FormData();
+ formData.append('id', 'tag123');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ // Mock linktags relationships
+ mockCollection.getList.mockResolvedValue({
+ items: [{ id: 'link_tag_1' }, { id: 'link_tag_2' }],
+ });
+
+ mockCollection.delete.mockResolvedValue(true);
+
+ const result = await actions.actions.delete({
+ request: mockRequest,
+ } as any);
+
+ // Should delete linktags first
+ expected(pb.collection).toHaveBeenCalledWith('linktags');
+ expect(mockCollection.getList).toHaveBeenCalledWith(1, 100, {
+ filter: `tag_id="tag123"`,
+ });
+ expect(mockCollection.delete).toHaveBeenCalledWith('link_tag_1');
+ expect(mockCollection.delete).toHaveBeenCalledWith('link_tag_2');
+
+ // Then delete the tag
+ expect(pb.collection).toHaveBeenCalledWith('tags');
+ expect(mockCollection.delete).toHaveBeenCalledWith('tag123');
+
+ expect(result).toEqual({ deleted: true });
+ });
+
+ it('should handle tags with no relationships', async () => {
+ const formData = new FormData();
+ formData.append('id', 'tag123');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.getList.mockResolvedValue({
+ items: [],
+ });
+
+ mockCollection.delete.mockResolvedValue(true);
+
+ const result = await actions.actions.delete({
+ request: mockRequest,
+ } as any);
+
+ expect(mockCollection.delete).toHaveBeenCalledTimes(1);
+ expect(mockCollection.delete).toHaveBeenCalledWith('tag123');
+ expect(result).toEqual({ deleted: true });
+ });
+
+ it('should fail if id is not provided', async () => {
+ const formData = new FormData();
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ const result = await actions.actions.delete({
+ request: mockRequest,
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Tag ID is required' });
+ expect(mockCollection.delete).not.toHaveBeenCalled();
+ });
+
+ it('should handle database errors', async () => {
+ const formData = new FormData();
+ formData.append('id', 'tag123');
+
+ const mockRequest = {
+ formData: vi.fn().mockResolvedValue(formData),
+ };
+
+ mockCollection.getList.mockRejectedValue(new Error('Database error'));
+
+ const result = await actions.actions.delete({
+ request: mockRequest,
+ } as any);
+
+ expect(fail).toHaveBeenCalledWith(400, { error: 'Failed to delete tag' });
+ });
+ });
+});
diff --git a/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte b/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte
new file mode 100644
index 000000000..40e052532
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/pricing/+page.svelte
@@ -0,0 +1,337 @@
+
+
+
+ Preise - ulo.ad
+
+
+
+
+
+
+
+
+
+
Wähle deinen Plan
+
+ Starte kostenlos und upgrade wenn du mehr brauchst
+
+
+ {#if wasCancelled}
+
+
+
+
+
+
+ Kein Problem! Du kannst jederzeit upgraden, wenn du bereit bist.
+
+
+
+ {/if}
+
+
+
+
+ {#each plans as plan}
+
+ {#if plan.popular}
+
+ {/if}
+
+
+
+
{plan.name}
+
+ {plan.price}
+ /{plan.period}
+
+ {#if plan.savings}
+
+ {plan.savings}
+
+ {/if}
+
+
+
+
+ {#each plan.features as feature}
+
+
+ {feature}
+
+ {/each}
+ {#each plan.limitations as limitation}
+
+
+ {limitation}
+
+ {/each}
+
+
+
+
+ {#if plan.priceType === null}
+
+ Aktueller Plan
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+
+
+
+
Häufige Fragen
+
+
+
(openFaq = openFaq === 1 ? null : 1)}
+ class="flex w-full items-center justify-between p-4 text-left"
+ >
+
+ Was ist der Unterschied zwischen den Pro-Plänen?
+
+
+
+
+
+ {#if openFaq === 1}
+
+
+ Alle Pro-Pläne haben die gleichen Features, unterscheiden sich aber im Preis:
+ Monatlich (4,99€/Monat), Jährlich (39,99€/Jahr - spare 20€), oder Lifetime (129,99€
+ einmalig - für immer Pro ohne weitere Zahlungen).
+
+
+ {/if}
+
+
+
+
(openFaq = openFaq === 2 ? null : 2)}
+ class="flex w-full items-center justify-between p-4 text-left"
+ >
+ Kann ich jederzeit upgraden?
+
+
+
+
+ {#if openFaq === 2}
+
+
+ Ja, du kannst jederzeit von Free zu Pro upgraden. Deine Links und Einstellungen
+ bleiben dabei erhalten. Du kannst auch zwischen den verschiedenen Pro-Plänen
+ wechseln.
+
+
+ {/if}
+
+
+
+
(openFaq = openFaq === 3 ? null : 3)}
+ class="flex w-full items-center justify-between p-4 text-left"
+ >
+ Lohnt sich der Lifetime-Plan?
+
+
+
+
+ {#if openFaq === 3}
+
+
+ Der Lifetime-Plan (129,99€) amortisiert sich bereits nach etwa 2,2 Jahren im
+ Vergleich zum monatlichen Plan. Du erhältst alle Pro-Features für immer, ohne
+ weitere monatliche Gebühren und hast Zugang zu allen zukünftigen Features.
+
+
+ {/if}
+
+
+
+
(openFaq = openFaq === 4 ? null : 4)}
+ class="flex w-full items-center justify-between p-4 text-left"
+ >
+ Kann ich jederzeit kündigen?
+
+
+
+
+ {#if openFaq === 4}
+
+
+ Ja, du kannst dein Abo jederzeit in den Einstellungen kündigen. Du behältst den
+ Zugang bis zum Ende des aktuellen Abrechnungszeitraums. Danach wechselst du
+ automatisch zum Free Plan.
+
+
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/settings/+page.svelte b/apps/uload/apps/web/src/routes/(app)/settings/+page.svelte
new file mode 100644
index 000000000..78b8f1298
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/settings/+page.svelte
@@ -0,0 +1,782 @@
+
+
+
+
+
+
+
+ Profile Information
+
+
+
{
+ isSubmitting = true;
+ return async ({ result, update }) => {
+ if (result.type === 'success') {
+ toastMessages.profileUpdated();
+ } else if (result.type === 'failure' && result.data?.error) {
+ notify.error(m.error_save(), result.data.error);
+ }
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ >
+
+
+
+
+ Profile Picture
+
+
+ {#if data.avatarUrl}
+
+ {:else}
+
+
+ {(data.user?.name || data.user?.username || 'U').charAt(0).toUpperCase()}
+
+
+ {/if}
+
+
+
JPG, PNG oder GIF. Max 5MB.
+
+
+
+
+
+
+ Username
+
+
+ {#if data.user?.username}
+
+ Profile URL: {formatUrl(data.user.username)} • Username kann nicht geändert werden
+
+ {/if}
+
+
+
+
+ Display Name
+
+
+
+
+
+
+ Email Address
+
+
+
+
+
+
+ Bio
+
+
+
+
+
+
+ Location
+
+
+
+
+
+
+
+ Profile Appearance
+
+
+
+
+ Profile Background
+
+
+
+ {
+ const input = document.getElementById('profileBackground') as HTMLInputElement;
+ if (input && e.currentTarget.value) {
+ input.value = e.currentTarget.value;
+ }
+ }}
+ class="rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-theme-text"
+ >
+ Custom Color
+ Light Gray (Default)
+ Light Blue
+ Light Green
+ Light Yellow
+ Light Pink
+ Light Purple
+ Dark Gray
+ Dark Blue
+ Black
+
+
+ Choose a color for your profile page background
+
+
+
+
+
+
+
Card Appearance
+
+ Customize the colors of your cards to match your style
+
+
+
+
+
+
+ Card Background
+
+
+
+ Background color
+
+
+
+
+
+
+ Card Border
+
+
+
+ Border color
+
+
+
+
+
+
+ Card Links/Buttons
+
+
+
+ Link & button color
+
+
+
+
+
+
+ Card Text
+
+
+
+ Text color
+
+
+
+
+
+
+
Quick Presets
+
+ setCardColors('#ffffff', '#e2e8f0', '#0ea5e9', '#0f172a')}
+ class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
+ >
+ Default
+
+ setCardColors('#f8fafc', '#cbd5e1', '#3b82f6', '#1e293b')}
+ class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
+ >
+ Cool Blue
+
+ setCardColors('#f0fdf4', '#bbf7d0', '#22c55e', '#166534')}
+ class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
+ >
+ Fresh Green
+
+ setCardColors('#1f2937', '#374151', '#60a5fa', '#f3f4f6')}
+ class="rounded-md border border-theme-border bg-theme-surface px-3 py-1 text-xs text-theme-text hover:bg-theme-surface-hover"
+ >
+ Dark Mode
+
+
+
+
+
+
+
+
+
+
+ {isSubmitting ? 'Saving...' : 'Save Changes'}
+
+
+
+
+ {#if form?.success}
+
+ {form.message}
+
+ {/if}
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+
+
+
+
Change Password
+
+
{
+ isSubmitting = true;
+ return async ({ result, update }) => {
+ if (result.type === 'success') {
+ toastMessages.passwordChanged();
+ } else if (result.type === 'failure' && result.data?.error) {
+ notify.error(m.error_password_change(), result.data.error);
+ }
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ >
+
+
+
+ {#if form?.passwordSuccess}
+
+ {form.passwordMessage}
+
+ {/if}
+ {#if form?.passwordError}
+
+ {form.passwordError}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Current Plan
+
+ {data.user?.subscription_status || 'free'}
+
+
+
+
Team Members
+
+ {#if data.user?.subscription_status === 'free'}
+ 0 / 1
+ {:else if data.user?.subscription_status === 'pro'}
+ 0 / 3
+ {:else if data.user?.subscription_status === 'team'}
+ 0 / 10
+ {:else if data.user?.subscription_status === 'team_plus'}
+ 0 / ∞
+ {:else}
+ 0 / 1
+ {/if}
+
+
+
+
+
+
+
+
+
Preferences
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ >
+
+
+
+ {#if form?.preferencesSuccess}
+
+ {form.preferencesMessage}
+
+ {/if}
+ {#if form?.preferencesError}
+
+ {form.preferencesError}
+
+ {/if}
+
+
+
+
+
Danger Zone
+
+
+
Delete Account
+
+ Once you delete your account, there is no going back. All your links and data will be
+ permanently removed.
+
+
+ {#if !showDeleteConfirm}
+
(showDeleteConfirm = true)}
+ class="rounded-lg bg-red-600 px-6 py-2 font-medium text-white transition-colors hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600"
+ >
+ Delete My Account
+
+ {:else}
+
+
+ Please type DELETE to confirm:
+
+
+
+
{
+ if (deleteConfirmText !== 'DELETE') {
+ alert('Please type DELETE to confirm');
+ return () => {};
+ }
+ return async ({ update }) => {
+ await update();
+ };
+ }}
+ >
+
+ Permanently Delete Account
+
+
+ {
+ showDeleteConfirm = false;
+ deleteConfirmText = '';
+ }}
+ class="rounded-lg border border-theme-border bg-white px-6 py-2 font-medium text-theme-text transition-colors hover:bg-theme-surface dark:border-theme-border dark:bg-theme-surface dark:text-theme-text dark:hover:bg-theme-surface"
+ >
+ Cancel
+
+
+
+ {/if}
+
+ {#if form?.deleteError}
+
+ {form.deleteError}
+
+ {/if}
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/settings/team/+page.svelte b/apps/uload/apps/web/src/routes/(app)/settings/team/+page.svelte
new file mode 100644
index 000000000..edec6efbc
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/settings/team/+page.svelte
@@ -0,0 +1,285 @@
+
+
+
+
+
+
Team Management
+
Invite team members to collaborate on your account
+
+
+
+
+
+
+
Current Plan
+
+ {data.user?.subscription_status || 'free'}
+
+
+
+
Team Members
+
+ {data.teamMembers?.length || 0}{teamLimit > 0 ? ` / ${teamLimit}` : ''}
+
+
+
+
+ {#if teamLimit > 0 && (data.teamMembers?.length || 0) >= teamLimit}
+
+
+
+
+
Team member limit reached
+
+ Upgrade to a higher plan for more team members.
+
+
+ View Plans →
+
+
+
+
+ {/if}
+
+
+
+
+
+
+ Invite Team Member
+
+
+ {#if teamLimit === 0 || remainingSlots > 0}
+
{
+ isInviting = true;
+ return async ({ update }) => {
+ await update();
+ isInviting = false;
+ if (form?.success) {
+ inviteEmail = '';
+ }
+ };
+ }}
+ >
+
+
+
+
+ Send Invite
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+
+ ✓ Invitation created successfully!
+
+
+ ⚠️ E-Mail-Versand ist möglicherweise nicht konfiguriert. Du findest den Einladungslink
+ unten zum manuellen Teilen.
+
+
+ {/if}
+ {:else}
+
+
+ You've reached your team member limit. Upgrade to Team Plus for more members.
+
+
+ {/if}
+
+
+
+ {#if data.teamMembers && data.teamMembers.length > 0}
+
+
+
+
+ Team Members ({data.teamMembers.length})
+
+
+
+
+ {#each data.teamMembers as member}
+
+
+
+ {member.user?.email?.[0]?.toUpperCase() || 'U'}
+
+
+
+ {member.user?.name || member.user?.email}
+
+
+ {member.user?.email}
+
+
+
+
+
+ {#if member.invitation_status === 'pending'}
+
+ Pending
+
+ {:else if member.invitation_status === 'accepted'}
+
+ Active
+
+ {/if}
+
+ {#if member.permissions?.manage_team}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+ {:else}
+
+
+
No team members yet. Invite someone to get started!
+
+ {/if}
+
+
+ {#if (data.pendingInvites && data.pendingInvites.length > 0) || (data.pendingNewUserInvites && data.pendingNewUserInvites.length > 0)}
+
+
+
Pending Invitations
+
+
+
+ {#each data.pendingInvites as invite}
+
+
+
+ {invite.expand?.user?.email || 'Unknown'}
+
+
+ Invited {new Date(invite.invited_at).toLocaleDateString()} · Existing user
+
+
+
+
+
+
+
+ Resend
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ {/each}
+
+ {#each data.pendingNewUserInvites || [] as invite}
+
+
+
+
+ {invite.email}
+
+
+ Invited {new Date(invite.created).toLocaleDateString()} · New user (needs to sign up)
+
+
+
+
+ {
+ const url = `${window.location.origin}/register?invite=${invite.token}`;
+ navigator.clipboard.writeText(url);
+ // Simple feedback
+ event.target.textContent = 'Copied!';
+ setTimeout(() => {
+ event.target.textContent = 'Copy invite link';
+ }, 2000);
+ }}
+ class="text-sm text-theme-primary hover:underline"
+ >
+ Copy invite link
+
+
+
+
+
+
+ Invite link:
+ {window.location.origin}/register?invite={invite.token}
+
+
+ {/each}
+
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/routes/(app)/settings/workspaces/+page.svelte b/apps/uload/apps/web/src/routes/(app)/settings/workspaces/+page.svelte
new file mode 100644
index 000000000..cdd00b739
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/settings/workspaces/+page.svelte
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
Workspaces
+
+ Manage your workspaces and switch between different contexts
+
+
+
+ {data.teamWorkspaces.length} von {data.user?.subscription_tier === 'pro'
+ ? '10'
+ : data.user?.subscription_tier === 'business'
+ ? 'unbegrenzt'
+ : '1'} Team-Workspaces erstellt
+
+
+
goto('/settings/workspaces/new')}
+ class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
+ >
+
+ Create Workspace
+
+
+
+
+
+
+ {#if data.personalWorkspace}
+
+
+
+
+
+
+
+
+ {data.personalWorkspace.name}
+
+
Personal Workspace
+
+
+
+ Active
+
+
+
+ {#if data.personalWorkspace.description}
+
+ {data.personalWorkspace.description}
+
+ {/if}
+
+
+
+ {data.personalStats?.links || 0} links
+ {data.personalStats?.clicks || 0} clicks
+
+
+ openWorkspace(data.personalWorkspace)}
+ class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ title="Open workspace"
+ >
+
+
+ goto(`/settings/workspaces/${data.personalWorkspace?.id}`)}
+ class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ title="Settings"
+ >
+
+
+
+
+
+ {/if}
+
+
+ {#each data.teamWorkspaces as workspace}
+
+
+
+
+
+
+
+
+ {workspace.name}
+
+
Team Workspace
+
+
+ {#if workspace.owner === data.user?.id}
+
+ Owner
+
+ {:else}
+
+ Member
+
+ {/if}
+
+
+ {#if workspace.description}
+
+ {workspace.description}
+
+ {/if}
+
+
+
+ {workspace.memberCount || 0} members
+
+
+ openWorkspace(workspace)}
+ class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ title="Open workspace"
+ >
+
+
+ goto(`/settings/workspaces/${workspace.id}`)}
+ class="rounded p-2 text-theme-text-muted transition-colors hover:bg-theme-surface-hover hover:text-theme-text"
+ title="Settings"
+ >
+
+
+
+
+
+ {/each}
+
+
+ {#each data.invitations as invitation}
+
+
+
+
+
+
+
+
+ {invitation.expand?.workspace?.name || 'Workspace Invitation'}
+
+
Pending Invitation
+
+
+
+ Pending
+
+
+
+
You've been invited to join this workspace
+
+
+ goto(`/team/accept-invite?token=${invitation.invitation_token}`)}
+ class="flex-1 rounded-lg bg-theme-primary px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
+ >
+ Accept Invitation
+
+
+ Decline
+
+
+
+ {/each}
+
+
+
+ {#if !data.personalWorkspace && data.teamWorkspaces.length === 0 && data.invitations.length === 0}
+
+
+
No workspaces yet
+
+ Create your first workspace to start organizing your links
+
+
goto('/settings/workspaces/new')}
+ class="inline-flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
+ >
+
+ Create Workspace
+
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/routes/(app)/settings/workspaces/[id]/+page.svelte b/apps/uload/apps/web/src/routes/(app)/settings/workspaces/[id]/+page.svelte
new file mode 100644
index 000000000..b8e9956ab
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/settings/workspaces/[id]/+page.svelte
@@ -0,0 +1,420 @@
+
+
+
+
+
+
goto('/settings')}
+ class="mb-4 flex items-center gap-2 text-sm text-theme-text-muted hover:text-theme-text"
+ >
+
+ Back to Settings
+
+
+
+
+
{data.workspace?.name}
+
Manage your workspace settings and team members
+
+ {#if data.workspace?.type === 'team'}
+
+
+ Team Workspace
+
+
+ {/if}
+
+
+
+
+
+
+ (activeTab = 'general')}
+ class="border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab === 'general'
+ ? 'border-theme-primary text-theme-primary'
+ : 'border-transparent text-theme-text-muted hover:text-theme-text'}"
+ >
+
+
+ General
+
+
+ (activeTab = 'members')}
+ class="border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab === 'members'
+ ? 'border-theme-primary text-theme-primary'
+ : 'border-transparent text-theme-text-muted hover:text-theme-text'}"
+ >
+
+
+ Members ({data.members?.length || 0})
+
+
+ {#if isOwner}
+ (activeTab = 'danger')}
+ class="border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab === 'danger'
+ ? 'border-red-500 text-red-500'
+ : 'border-transparent text-theme-text-muted hover:text-red-500'}"
+ >
+
+
+ Danger Zone
+
+
+ {/if}
+
+
+
+
+
+ {#if activeTab === 'general'}
+
+
+
General Settings
+
+
{
+ isSaving = true;
+ return async ({ update }) => {
+ await update();
+ isSaving = false;
+ isEditing = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+ Workspace Name
+
+
+
+
+
+
+ Workspace URL Slug
+
+
+ /w/
+
+
+
+ Used for workspace links: /w/{workspaceSlug || 'workspace-slug'}/shortcode
+
+
+
+
+
+ Description
+
+
+
+
+ {#if canManage}
+
+ {#if isEditing}
+ {
+ isEditing = false;
+ workspaceName = data.workspace?.name || '';
+ workspaceDescription = data.workspace?.description || '';
+ workspaceSlug = data.workspace?.slug || '';
+ }}
+ class="rounded-lg px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
+ >
+ Cancel
+
+
+ {isSaving ? 'Saving...' : 'Save Changes'}
+
+ {:else}
+ (isEditing = true)}
+ class="rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
+ >
+ Edit Settings
+
+ {/if}
+
+ {/if}
+
+
+ {:else if activeTab === 'members'}
+
+
+
+
Team Members
+ {#if canManage}
+ {
+ /* Open invite modal */
+ }}
+ class="flex items-center gap-2 rounded-lg bg-theme-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-theme-primary-hover"
+ >
+
+ Invite Member
+
+ {/if}
+
+
+ {#if canManage}
+
+
+
{
+ isInviting = true;
+ return async ({ update }) => {
+ await update();
+ isInviting = false;
+ inviteEmail = '';
+ };
+ }}
+ class="flex gap-3"
+ >
+
+
+ Member
+ Admin
+
+
+
+ Send Invite
+
+
+
+ {/if}
+
+
+
+ {#each data.members || [] as member}
+
+
+
+ {member.expand?.user?.email?.[0]?.toUpperCase() || 'U'}
+
+
+
+ {member.expand?.user?.name || member.expand?.user?.email}
+
+
+ {member.expand?.user?.email}
+
+
+
+
+
+ {#if member.role === 'owner'}
+
+ Owner
+
+ {:else if member.role === 'admin'}
+
+ Admin
+
+ {:else}
+
+ Member
+
+ {/if}
+
+ {#if member.invitation_status === 'pending'}
+
+ Pending
+
+ {/if}
+
+ {#if canManage && member.role !== 'owner'}
+
+
+
+
+
+
+ {/if}
+
+
+ {/each}
+
+
+ {:else if activeTab === 'danger'}
+
+
+
Danger Zone
+
+
+
Delete Workspace
+
+ {#if data.workspace?.type === 'personal'}
+
+ Personal workspaces cannot be deleted. They are permanently associated with your
+ account.
+
+
+ Cannot Delete Personal Workspace
+
+ {:else}
+
+ Once you delete a workspace, there is no going back. All links, settings, and team
+ access will be permanently removed.
+
+
{
+ return async ({ result, update }) => {
+ if (result.type === 'success' && result.data?.deleted) {
+ // Workspace was deleted successfully, navigate and refresh
+ await goto('/settings/workspaces', { invalidateAll: true });
+ } else if (result.type === 'redirect') {
+ // Handle redirect (shouldn't happen now but keep as fallback)
+ await goto('/settings/workspaces', { invalidateAll: true });
+ } else {
+ // Handle errors
+ await update();
+ }
+ };
+ }}
+ onsubmit={(e) => {
+ if (
+ !confirm(
+ 'Are you sure you want to delete this workspace? This action cannot be undone.'
+ )
+ ) {
+ e.preventDefault();
+ }
+ }}
+ >
+
+ Delete Workspace
+
+
+ {/if}
+
+
+ {/if}
+
+
+
+ {#if form?.success}
+
+
+ {form.message || 'Changes saved successfully'}
+
+
+ {/if}
+
+ {#if form?.error}
+
+ {/if}
+
diff --git a/apps/uload/apps/web/src/routes/(app)/settings/workspaces/new/+page.svelte b/apps/uload/apps/web/src/routes/(app)/settings/workspaces/new/+page.svelte
new file mode 100644
index 000000000..65f6f608b
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/settings/workspaces/new/+page.svelte
@@ -0,0 +1,276 @@
+
+
+
+
+
+
goto('/settings')}
+ class="mb-4 flex items-center gap-2 text-sm text-theme-text-muted hover:text-theme-text"
+ >
+
+ Back to Settings
+
+
+
Create New Workspace
+
Set up a new workspace for your team or project
+ {#if data?.workspaceCount !== undefined && data?.workspaceLimit !== undefined}
+
+ You have created {data.workspaceCount} of {data.workspaceLimit} team workspaces
+
+ {/if}
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ result, update }) => {
+ await update();
+ isSubmitting = false;
+ if (result.type === 'redirect') {
+ // Handle redirect
+ }
+ };
+ }}
+ class="space-y-6 p-6"
+ >
+
+
+
Workspace Type
+
+
(workspaceType = 'team')}
+ class="relative rounded-lg border-2 p-4 transition-all {workspaceType === 'team'
+ ? 'bg-theme-primary/5 border-theme-primary'
+ : 'hover:border-theme-primary/50 border-theme-border'}"
+ >
+
+
+
+
Team Workspace
+
Collaborate with team members
+
+
+ {#if workspaceType === 'team'}
+
+ {/if}
+
+
+
(workspaceType = 'personal')}
+ class="relative rounded-lg border-2 p-4 transition-all {workspaceType === 'personal'
+ ? 'bg-theme-primary/5 border-theme-primary'
+ : 'hover:border-theme-primary/50 border-theme-border'}"
+ disabled
+ >
+
+
+
+
Personal Workspace
+
+ You already have a personal workspace
+
+
+
+
+
+
+
+
+
+
+
+ Workspace Name *
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+ Workspace URL (optional)
+
+
+
+ ulo.ad/w/
+
+
+
+
+ {#if workspaceSlug}
+
+ {#if isSlugValid}
+
+
Available workspace URL
+ {:else}
+
+
{slugValidation}
+ {/if}
+
+ {:else}
+
+ Only lowercase letters, numbers, and hyphens. Leave empty for auto-generated.
+
+ {/if}
+
+
+
+
+
+
+
+
+ 🔒 Workspace URL Protection
+
+
+ Some workspace URLs are reserved to prevent conflicts with existing user profiles,
+ system routes, and well-known brands. This protects against confusion and potential
+ phishing attempts.
+
+
+
+
+
+
+
+
What you'll get:
+
+
+
+ Separate link collection for this workspace
+
+
+
+ Invite team members with specific permissions
+
+
+
+ Workspace analytics and statistics
+
+
+
+ Quick workspace switching
+
+
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+
+
+
goto('/settings')}
+ class="rounded-lg px-4 py-2 text-sm font-medium text-theme-text transition-colors hover:bg-theme-surface-hover"
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Workspace
+ {/if}
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/setup-username/+page.svelte b/apps/uload/apps/web/src/routes/(app)/setup-username/+page.svelte
new file mode 100644
index 000000000..cefcda166
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/setup-username/+page.svelte
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
Wähle deinen Username
+
+ Dies ist deine einmalige Chance, deinen Username zu wählen. Nach der Bestätigung kann er
+ nicht mehr geändert werden.
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ result, update }) => {
+ if (result.type === 'failure') {
+ // Show error toast
+ if (result.data?.error) {
+ toastMessages.genericError(result.data.error);
+ }
+ await update();
+ isSubmitting = false;
+ } else if (result.type === 'redirect') {
+ // Let the redirect happen
+ } else if (result.type === 'success' && result.data?.success) {
+ // Show success toast and redirect
+ toastMessages.usernameSet(username);
+ setTimeout(() => {
+ window.location.href = '/my';
+ }, 1500);
+ } else {
+ await update();
+ isSubmitting = false;
+ }
+ };
+ }}
+ >
+
+
+
+ Username
+
+
+
+ {#if isChecking}
+
+ {/if}
+
+
+ {#if validationMessage}
+
+ {validationMessage}
+
+ {/if}
+
+
+
+ Deine Profil-URL wird sein:
+
+ {typeof window !== 'undefined' ? window.location.origin : ''}/p/{username ||
+ 'dein-username'}
+
+
+
+
+
+
+
+
+
+
+
+
+ Wichtiger Hinweis
+
+
+ Nach der Bestätigung kann dein Username nie wieder geändert werden.
+ Alle deine Links werden unter diesem Username erreichbar sein.
+
+
+
+
+
+
+
+ Username-Regeln:
+
+
+ • Mindestens 3, maximal 30 Zeichen
+ • Nur Buchstaben, Zahlen, Unterstriche (_) und Bindestriche (-)
+ • Muss mit einem Buchstaben oder einer Zahl beginnen
+ • Keine Leerzeichen oder Sonderzeichen
+
+
+
+
+ {#if isSubmitting}
+ Username wird gesetzt...
+ {:else}
+ Username bestätigen
+ {/if}
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(app)/template-store/+page.svelte b/apps/uload/apps/web/src/routes/(app)/template-store/+page.svelte
new file mode 100644
index 000000000..4c819851e
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(app)/template-store/+page.svelte
@@ -0,0 +1,314 @@
+
+
+
+ Template Store - uload
+
+
+
+
+
+
+
+
Template Store
+
Discover and use community-created card templates
+
+
+
+
+
+
+
+
+
+
+
+ {#each categories as category}
+ (selectedCategory = category.value)}
+ class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
+ category.value
+ ? 'bg-theme-primary text-white'
+ : 'bg-theme-surface text-theme-text hover:bg-theme-surface-hover'}"
+ >
+ {category.label}
+
+ {/each}
+
+
+
+
+ Most Popular
+ Most Recent
+ Most Liked
+
+
+
+
+
+ {#if loading}
+
+
+
+
Loading templates...
+
+
+ {:else if filteredTemplates().length > 0}
+
+ {#each filteredTemplates() as template}
+
+ {/each}
+
+ {:else}
+
+
+
+
+
No templates found
+
Try adjusting your search or filters
+
+ {/if}
+
+
+ {#if pb.authStore.model}
+
+
Share Your Creations
+
Create and share your own card templates with the community
+
+ Create Template
+
+
+ {/if}
+
+
+
+
+ (showPreview = false)}
+ onUse={useTemplate}
+ onDuplicate={duplicateTemplate}
+ onLike={toggleLike}
+/>
diff --git a/apps/uload/apps/web/src/routes/(auth)/+layout.svelte b/apps/uload/apps/web/src/routes/(auth)/+layout.svelte
new file mode 100644
index 000000000..0bb694b7e
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/+layout.svelte
@@ -0,0 +1,15 @@
+
+
+{@render children()}
diff --git a/apps/uload/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/uload/apps/web/src/routes/(auth)/forgot-password/+page.svelte
new file mode 100644
index 000000000..3664c6728
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/forgot-password/+page.svelte
@@ -0,0 +1,43 @@
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(auth)/login/+page.svelte b/apps/uload/apps/web/src/routes/(auth)/login/+page.svelte
new file mode 100644
index 000000000..d44ca1df8
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/login/+page.svelte
@@ -0,0 +1,62 @@
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(auth)/register/+page.svelte b/apps/uload/apps/web/src/routes/(auth)/register/+page.svelte
new file mode 100644
index 000000000..018e1c57d
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/register/+page.svelte
@@ -0,0 +1,89 @@
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(auth)/register/register.test.ts b/apps/uload/apps/web/src/routes/(auth)/register/register.test.ts
new file mode 100644
index 000000000..8dbb6dc09
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/register/register.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { registerUser } from '$lib/auth-helper';
+
+describe('User Registration', () => {
+ const testEmail = () => `test${Date.now()}@example.com`;
+
+ it('should register user with email and password only', async () => {
+ const email = testEmail();
+ const password = 'TestPassword123!';
+
+ const result = await registerUser({
+ email,
+ password,
+ passwordConfirm: password,
+ });
+
+ // May fail in test environment without PocketBase
+ if (process.env.PUBLIC_POCKETBASE_URL) {
+ expect(result.success).toBe(true);
+ expect(result.user).toBeDefined();
+ expect(result.user?.email).toBe(email.toLowerCase());
+ expect(result.user?.username).toBeDefined();
+ }
+ });
+
+ it('should validate email format', async () => {
+ const result = await registerUser({
+ email: 'invalid-email',
+ password: 'TestPassword123!',
+ passwordConfirm: 'TestPassword123!',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('email');
+ });
+
+ it('should validate password match', async () => {
+ const result = await registerUser({
+ email: testEmail(),
+ password: 'Password123!',
+ passwordConfirm: 'DifferentPassword123!',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('match');
+ });
+
+ it('should generate unique username from email', async () => {
+ const email = 'john.doe+test@example.com';
+ const password = 'TestPassword123!';
+
+ const result = await registerUser({
+ email,
+ password,
+ passwordConfirm: password,
+ });
+
+ if (process.env.PUBLIC_POCKETBASE_URL && result.success) {
+ expect(result.user?.username).toBeDefined();
+ expect(result.user?.username).toMatch(/^[a-zA-Z0-9_-]+$/);
+ }
+ });
+
+ it('should handle duplicate email gracefully', async () => {
+ const email = testEmail();
+ const password = 'TestPassword123!';
+
+ // First registration
+ await registerUser({
+ email,
+ password,
+ passwordConfirm: password,
+ });
+
+ // Second registration with same email
+ const result = await registerUser({
+ email,
+ password,
+ passwordConfirm: password,
+ });
+
+ if (process.env.PUBLIC_POCKETBASE_URL) {
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('already');
+ }
+ });
+});
diff --git a/apps/uload/apps/web/src/routes/(auth)/reset-password/+page.svelte b/apps/uload/apps/web/src/routes/(auth)/reset-password/+page.svelte
new file mode 100644
index 000000000..0f9b91f0c
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/reset-password/+page.svelte
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+ {#if !token}
+
+
+
+
+
{m.auth_invalid_reset_link()}
+
+ {m.auth_invalid_reset_link_message()}
+
+
+
+ {:else if form?.success}
+
+
+
+
+
{m.auth_password_reset_success()}
+
+ {m.auth_password_reset_success_message()}
+
+
+
+ {:else}
+
+
+
+
+
{m.auth_set_new_password_title()}
+
+ {m.auth_set_new_password_subtitle()}
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ result, update }) => {
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ >
+
+
+
+
+
+ {m.auth_new_password_label()}
+
+
+
+
+
+
+ {m.auth_confirm_new_password_label()}
+
+
+
+
+ {#if form?.error}
+
+ ⚠️ {form.error}
+
+ {/if}
+
+
+ {#if isSubmitting}
+
+
+
+
+ {m.auth_reset_password_button_loading()}
+ {:else}
+ {m.auth_reset_password_button()}
+ {/if}
+
+
+
+
+
+
+ {m.auth_remember_password()}
+
+ {m.auth_back_to_login()}
+
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/routes/(auth)/verify-email/+page.svelte b/apps/uload/apps/web/src/routes/(auth)/verify-email/+page.svelte
new file mode 100644
index 000000000..acef1a77d
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/(auth)/verify-email/+page.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Verifying Email / E-Mail wird verifiziert
+
+
Please wait... / Bitte warten...
+
+
+
diff --git a/apps/uload/apps/web/src/routes/+layout.svelte b/apps/uload/apps/web/src/routes/+layout.svelte
new file mode 100644
index 000000000..80cbc8a1c
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/+layout.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+{#if loading}
+
+{:else}
+ {@render children?.()}
+{/if}
+
+
diff --git a/apps/uload/apps/web/src/routes/+page.svelte b/apps/uload/apps/web/src/routes/+page.svelte
new file mode 100644
index 000000000..1ec965c19
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/+page.svelte
@@ -0,0 +1,612 @@
+
+
+{#if showAuthModal}
+
+
+
+ Sign in to unlock all features
+
+
+ This feature is only available for registered users. Sign in to:
+
+
+
+ ✓ Create short links
+
+
+ ✓ Set custom titles and descriptions
+
+
+ ✓ Add expiration dates
+
+
+ ✓ Password protect links
+
+
+ ✓ Track analytics
+
+
+
+
+ Sign In
+
+
(showAuthModal = false)}
+ class="flex-1 rounded-lg border border-gray-300 bg-white px-4 py-2 font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
+ >
+ Cancel
+
+
+
+
+{/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if data.globalStats}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/[...slug]/+page.svelte b/apps/uload/apps/web/src/routes/[...slug]/+page.svelte
new file mode 100644
index 000000000..0b7e221cf
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/[...slug]/+page.svelte
@@ -0,0 +1,67 @@
+
+
+{#if data.requiresPassword}
+
+
+
+
Password Protected Link
+
This link requires a password to access.
+
+
+
+
+
+ Password
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+
+ Continue
+
+
+
+
+
+
+{:else}
+
+
+
+
Redirecting...
+
+ If you are not redirected, there may be an issue with this link.
+
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/routes/checkout/success/+page.svelte b/apps/uload/apps/web/src/routes/checkout/success/+page.svelte
new file mode 100644
index 000000000..5f0bba860
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/checkout/success/+page.svelte
@@ -0,0 +1,144 @@
+
+
+
+ Zahlung erfolgreich - ulo.ad
+
+
+
+
+ {#if verifying}
+
+
Verifiziere deine Zahlung...
+ {:else}
+
+
+
Willkommen bei ulo.ad Pro! 🎉
+
+ Dein Upgrade war erfolgreich. Du hast jetzt Zugriff auf alle Pro Features!
+
+
+
+
Was du jetzt kannst:
+
+
+
+
+
+ Unbegrenzt Links erstellen
+
+
+
+
+
+ Erweiterte Analytics nutzen
+
+
+
+
+
+ Custom QR Codes erstellen
+
+
+
+
+
+ Priority Support erhalten
+
+
+
+
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/routes/offline/+page.svelte b/apps/uload/apps/web/src/routes/offline/+page.svelte
new file mode 100644
index 000000000..bc5346efa
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/offline/+page.svelte
@@ -0,0 +1,130 @@
+
+
+
+ Offline - uLoad
+
+
+
+
+
+
+
+
+
+
+
You're offline
+
+
+ It looks like you've lost your internet connection. Don't worry, you can still browse
+ previously visited pages that have been cached.
+
+
+
+
+
+
+
+ {isOnline ? 'Back online!' : 'Offline'}
+
+
+
+
+
+
+
+
+
+
While you're offline, you can:
+
+
+ •
+ Browse previously visited pages
+
+
+ •
+ View cached link analytics
+
+
+ •
+ Check your profile information
+
+
+
+
+
+
+
uLoad works best with a stable internet connection
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/p/[username]/+page.svelte b/apps/uload/apps/web/src/routes/p/[username]/+page.svelte
new file mode 100644
index 000000000..1f31e1215
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/p/[username]/+page.svelte
@@ -0,0 +1,277 @@
+
+
+
+ {data.profileUser.name || data.profileUser.username} | Uload
+
+
+
+
+
+
+
+
+
+ {#if data.profileUser.avatarUrl}
+
+ {:else}
+
+
+ {(data.profileUser.name || data.profileUser.username).charAt(0).toUpperCase()}
+
+
+ {/if}
+
+
+
+ {data.profileUser.name || data.profileUser.username}
+
+
@{data.profileUser.username}
+
+
+ {#if data.profileUser.bio}
+
{data.profileUser.bio}
+ {/if}
+
+
+
+
+
+
+
+ {#if data.cards && data.cards.length > 0}
+
+
+
Featured Cards
+
+ {#each data.cards as card}
+
+ {#if card.config?.mode === 'beginner' && card.config.modules}
+
+
+ {#each card.config.modules as module}
+ {#if module.type === 'header'}
+
+ {#if module.props?.title}
+
{module.props.title}
+ {/if}
+ {#if module.props?.subtitle}
+
{module.props.subtitle}
+ {/if}
+
+ {:else if module.type === 'content'}
+
+
+ {module.props?.text || module.props?.html || ''}
+
+
+ {:else if module.type === 'media' && module.props?.type === 'image'}
+
+
+
+ {:else if module.type === 'links' && module.props?.links}
+
+ {/if}
+ {/each}
+
+ {:else if card.config?.mode === 'advanced'}
+
+
+
+ {#if card.config.template}
+
+
+ Template Card
+
+ {#if card.metadata?.name}
+
+ {card.metadata.name}
+
+ {/if}
+ {#if card.metadata?.description}
+
{card.metadata.description}
+ {/if}
+ {/if}
+
+
+ {:else if card.config?.mode === 'expert'}
+
+
+
+
Custom Card
+ {#if card.metadata?.name}
+
{card.metadata.name}
+ {/if}
+ {#if card.metadata?.description}
+
{card.metadata.description}
+ {/if}
+
+
+ {:else}
+
+
+
Card content unavailable
+
+ {/if}
+
+ {/each}
+
+
+ {:else}
+
+
+
+ Welcome to {data.profileUser.name || data.profileUser.username}'s profile.
+
+
No content available yet.
+
+ {/if}
+
+
+
+
+
diff --git a/apps/uload/apps/web/src/routes/page.svelte.spec.ts b/apps/uload/apps/web/src/routes/page.svelte.spec.ts
new file mode 100644
index 000000000..3c6adf306
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/page.svelte.spec.ts
@@ -0,0 +1,13 @@
+import { page } from '@vitest/browser/context';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import Page from './+page.svelte';
+
+describe('/+page.svelte', () => {
+ it('should render h1', async () => {
+ render(Page);
+
+ const heading = page.getByRole('heading', { level: 1 });
+ await expect.element(heading).toBeInTheDocument();
+ });
+});
diff --git a/apps/uload/apps/web/src/routes/preview/+page.svelte b/apps/uload/apps/web/src/routes/preview/+page.svelte
new file mode 100644
index 000000000..db1abdeef
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/preview/+page.svelte
@@ -0,0 +1,50 @@
+
+
+
+ Card Preview - uload
+
+
+
+
+
+
Card Preview
+
This is how your card will look
+
+
+ {#if card}
+
+
+
+ {:else}
+
+
No card data found for preview
+
window.close()}
+ class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
+ >
+ Close Preview
+
+
+ {/if}
+
+
diff --git a/apps/uload/apps/web/src/routes/team/accept-invite/+page.svelte b/apps/uload/apps/web/src/routes/team/accept-invite/+page.svelte
new file mode 100644
index 000000000..b5a8ceeee
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/team/accept-invite/+page.svelte
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
ulo.ad
+
+
+ {#if loading}
+
+
+
Processing invitation...
+
+ {:else if success}
+
+
+
Invitation Accepted!
+
You've successfully joined the team.
+
Redirecting to dashboard...
+
+ {:else if error}
+
+
+
Invitation Error
+
{error}
+
+
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/routes/u/[username]/+page.svelte b/apps/uload/apps/web/src/routes/u/[username]/+page.svelte
new file mode 100644
index 000000000..c2f8420ae
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/u/[username]/+page.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ {#if data.user.avatarUrl}
+
+ {:else}
+
+
+ {(data.user.name || data.user.username).charAt(0).toUpperCase()}
+
+
+ {/if}
+
+
+
+ {data.user.name || data.user.username}
+
+
@{data.user.username}
+ {#if data.user.bio}
+
{data.user.bio}
+ {/if}
+
+
+
+
+
+
+
Public Links
+
+ {#if data.links.length > 0}
+
+ {:else}
+
No public links available
+ {/if}
+
+
+
diff --git a/apps/uload/apps/web/src/routes/w/[workspace]/[...code]/+page.svelte b/apps/uload/apps/web/src/routes/w/[workspace]/[...code]/+page.svelte
new file mode 100644
index 000000000..7e26dd5c9
--- /dev/null
+++ b/apps/uload/apps/web/src/routes/w/[workspace]/[...code]/+page.svelte
@@ -0,0 +1,120 @@
+
+
+{#if data.requiresPassword && data.link}
+
+
+
+
+
+
+ Passwortgeschützter Link
+
+ {#if data.workspace}
+
+ Workspace: {data.workspace.name}
+
+ {/if}
+ {#if data.link.title}
+
+ {data.link.title}
+
+ {/if}
+
+
+
{
+ e.preventDefault();
+ handlePasswordSubmit();
+ }}
+ class="space-y-4"
+ >
+
+
+ Passwort eingeben
+
+
+ {#if error}
+
+ Falsches Passwort. Bitte versuchen Sie es erneut.
+
+ {/if}
+
+
+
+ Link öffnen →
+
+
+
+
+
+ Dieser Link ist passwortgeschützt. Geben Sie das korrekte Passwort ein, um fortzufahren.
+
+
+
+
+
+{:else}
+
+
+{/if}
diff --git a/apps/uload/apps/web/src/tests/factories/index.ts b/apps/uload/apps/web/src/tests/factories/index.ts
new file mode 100644
index 000000000..70e69418a
--- /dev/null
+++ b/apps/uload/apps/web/src/tests/factories/index.ts
@@ -0,0 +1,165 @@
+import type { User, Tag, Folder, Link, Click } from '$lib/pocketbase';
+
+let idCounter = 0;
+
+function generateId(): string {
+ return `test_${Date.now()}_${++idCounter}`;
+}
+
+export function createTestUser(overrides: Partial = {}): User {
+ return {
+ id: generateId(),
+ email: 'test@example.com',
+ username: 'testuser',
+ name: 'Test User',
+ avatar: '',
+ bio: 'Test bio',
+ location: 'Test Location',
+ website: 'https://example.com',
+ github: 'testuser',
+ twitter: 'testuser',
+ linkedin: 'testuser',
+ instagram: 'testuser',
+ publicProfile: true,
+ showClickStats: true,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export function createTestTag(overrides: Partial = {}): Tag {
+ return {
+ id: generateId(),
+ user_id: 'test_user_123',
+ name: 'Test Tag',
+ slug: 'test-tag',
+ color: '#3B82F6',
+ icon: '🏷️',
+ is_public: false,
+ usage_count: 0,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export function createTestFolder(overrides: Partial = {}): Folder {
+ return {
+ id: generateId(),
+ user_id: 'test_user_123',
+ name: 'test-folder',
+ display_name: 'Test Folder',
+ description: 'Test folder description',
+ icon: '📁',
+ color: '#3B82F6',
+ is_public: true,
+ order: 0,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export function createTestLink(overrides: Partial = {}): Link {
+ return {
+ id: generateId(),
+ user_id: 'test_user_123',
+ original_url: 'https://example.com',
+ short_code: 'abc123',
+ title: 'Test Link',
+ description: 'Test link description',
+ is_active: true,
+ expires_at: undefined,
+ password: undefined,
+ max_clicks: undefined,
+ use_username: false,
+ folder_id: undefined,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export function createTestClick(overrides: Partial = {}): Click {
+ return {
+ id: generateId(),
+ link_id: 'test_link_123',
+ ip_address: '127.0.0.1',
+ user_agent: 'Mozilla/5.0 Test Browser',
+ referer: 'https://google.com',
+ country: 'US',
+ device_type: 'desktop',
+ browser: 'Chrome',
+ clicked_at: new Date().toISOString(),
+ created: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export function createBatchTestTags(count: number, userId: string): Tag[] {
+ const tags: Tag[] = [];
+ const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6'];
+ const icons = ['🏷️', '📌', '⭐', '💡', '🔥'];
+
+ for (let i = 0; i < count; i++) {
+ tags.push(
+ createTestTag({
+ name: `Tag ${i + 1}`,
+ slug: `tag-${i + 1}`,
+ user_id: userId,
+ color: colors[i % colors.length],
+ icon: icons[i % icons.length],
+ usage_count: Math.floor(Math.random() * 10),
+ })
+ );
+ }
+
+ return tags;
+}
+
+export function createBatchTestFolders(count: number, userId: string): Folder[] {
+ const folders: Folder[] = [];
+ const colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6'];
+ const icons = ['📁', '📂', '🗂️', '📚', '💼'];
+
+ for (let i = 0; i < count; i++) {
+ folders.push(
+ createTestFolder({
+ name: `folder-${i + 1}`,
+ display_name: `Folder ${i + 1}`,
+ user_id: userId,
+ color: colors[i % colors.length],
+ icon: icons[i % icons.length],
+ order: i,
+ })
+ );
+ }
+
+ return folders;
+}
+
+export function createAuthError(message: string = 'Invalid credentials') {
+ return {
+ response: {
+ data: {
+ message,
+ code: 401,
+ },
+ },
+ };
+}
+
+export function createValidationError(field: string, message: string) {
+ return {
+ response: {
+ data: {
+ data: {
+ [field]: {
+ message,
+ },
+ },
+ },
+ },
+ };
+}
diff --git a/apps/uload/apps/web/src/tests/mocks/pocketbase.ts b/apps/uload/apps/web/src/tests/mocks/pocketbase.ts
new file mode 100644
index 000000000..faa48ec3d
--- /dev/null
+++ b/apps/uload/apps/web/src/tests/mocks/pocketbase.ts
@@ -0,0 +1,107 @@
+import { vi } from 'vitest';
+import type { Mock } from 'vitest';
+
+export interface MockCollection {
+ create: Mock;
+ update: Mock;
+ delete: Mock;
+ getList: Mock;
+ getOne: Mock;
+ getFirstListItem: Mock;
+ authWithPassword: Mock;
+}
+
+export interface MockPocketBase {
+ collection: Mock<[string], MockCollection>;
+ authStore: {
+ isValid: boolean;
+ token: string;
+ model: any;
+ clear: Mock;
+ };
+ baseUrl: string;
+}
+
+export function createMockPocketBase(): MockPocketBase {
+ return {
+ collection: vi.fn((name: string) => ({
+ create: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ getList: vi.fn(() =>
+ Promise.resolve({
+ items: [],
+ totalItems: 0,
+ totalPages: 0,
+ page: 1,
+ perPage: 20,
+ })
+ ),
+ getOne: vi.fn(),
+ getFirstListItem: vi.fn(() => Promise.reject(new Error('No items found'))),
+ authWithPassword: vi.fn(),
+ })),
+ authStore: {
+ isValid: false,
+ token: '',
+ model: null,
+ clear: vi.fn(),
+ },
+ baseUrl: 'http://localhost:8090',
+ };
+}
+
+export function mockSuccessfulAuth(pb: MockPocketBase, user: any) {
+ const collection = pb.collection('users') as MockCollection;
+ collection.authWithPassword.mockResolvedValue({
+ token: 'mock-token',
+ record: user,
+ });
+ pb.authStore.isValid = true;
+ pb.authStore.token = 'mock-token';
+ pb.authStore.model = user;
+}
+
+export function mockFailedAuth(pb: MockPocketBase, error: string = 'Invalid credentials') {
+ const collection = pb.collection('users') as MockCollection;
+ collection.authWithPassword.mockRejectedValue(new Error(error));
+}
+
+export function mockCreateSuccess(pb: MockPocketBase, collectionName: string, data: any) {
+ const collection = pb.collection(collectionName) as MockCollection;
+ collection.create.mockResolvedValue({
+ ...data,
+ id: 'mock-id-' + Date.now(),
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ });
+}
+
+export function mockCreateError(pb: MockPocketBase, collectionName: string, error: any) {
+ const collection = pb.collection(collectionName) as MockCollection;
+ collection.create.mockRejectedValue(error);
+}
+
+export function mockGetListSuccess(pb: MockPocketBase, collectionName: string, items: any[]) {
+ const collection = pb.collection(collectionName) as MockCollection;
+ collection.getList.mockResolvedValue({
+ items,
+ totalItems: items.length,
+ totalPages: 1,
+ page: 1,
+ perPage: 20,
+ });
+}
+
+export function mockUpdateSuccess(pb: MockPocketBase, collectionName: string, updatedData: any) {
+ const collection = pb.collection(collectionName) as MockCollection;
+ collection.update.mockResolvedValue({
+ ...updatedData,
+ updated: new Date().toISOString(),
+ });
+}
+
+export function mockDeleteSuccess(pb: MockPocketBase, collectionName: string) {
+ const collection = pb.collection(collectionName) as MockCollection;
+ collection.delete.mockResolvedValue(true);
+}
diff --git a/apps/uload/apps/web/src/tests/setup.ts b/apps/uload/apps/web/src/tests/setup.ts
new file mode 100644
index 000000000..d1a6545e9
--- /dev/null
+++ b/apps/uload/apps/web/src/tests/setup.ts
@@ -0,0 +1,240 @@
+// Global test setup für uLoad
+import { vi } from 'vitest';
+import type { Mock } from 'vitest';
+
+// Check if we're in browser environment
+const isBrowser = typeof window !== 'undefined';
+const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
+
+// Mock SvelteKit modules
+vi.mock('$app/environment', () => ({
+ browser: false,
+ dev: true,
+ building: false,
+ version: '1.0.0',
+}));
+
+vi.mock('$app/stores', () => ({
+ page: {
+ subscribe: vi.fn(() => () => {}),
+ },
+ updated: {
+ subscribe: vi.fn(() => () => {}),
+ },
+ navigating: {
+ subscribe: vi.fn(() => () => {}),
+ },
+}));
+
+vi.mock('$app/navigation', () => ({
+ goto: vi.fn(),
+ invalidate: vi.fn(),
+ invalidateAll: vi.fn(),
+ preloadData: vi.fn(),
+ pushState: vi.fn(),
+ replaceState: vi.fn(),
+}));
+
+// Mock PocketBase für Tests
+vi.mock('$lib/pocketbase', async () => {
+ const actual = await vi.importActual('$lib/pocketbase');
+
+ // Mock PocketBase-Instanz
+ const mockPb = {
+ authStore: {
+ model: null,
+ token: '',
+ isValid: false,
+ save: vi.fn(),
+ clear: vi.fn(),
+ onChange: vi.fn(() => () => {}),
+ },
+ collection: vi.fn(() => ({
+ create: vi.fn(),
+ getList: vi.fn(),
+ getOne: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ authWithPassword: vi.fn(),
+ authRefresh: vi.fn(),
+ requestPasswordReset: vi.fn(),
+ confirmPasswordReset: vi.fn(),
+ requestVerification: vi.fn(),
+ confirmVerification: vi.fn(),
+ })),
+ send: vi.fn(),
+ };
+
+ return {
+ ...actual,
+ pb: mockPb,
+ };
+});
+
+// Mock Toast Service
+vi.mock('$lib/services/toast', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+
+// Mock Theme Store
+vi.mock('$lib/theme.svelte', () => ({
+ themeMode: { value: 'light' },
+ themePreset: { value: 'minimal' },
+ applyTheme: vi.fn(),
+ initializeTheme: vi.fn(),
+}));
+
+// Global test utilities
+export const createMockEvent = (data: any = {}) => ({
+ request: {
+ method: 'GET',
+ url: new URL('http://localhost:5173'),
+ formData: async () => new FormData(),
+ json: async () => data,
+ ...data.request,
+ },
+ locals: {
+ pb: {
+ authStore: { model: null, isValid: false },
+ collection: vi.fn(() => ({
+ create: vi.fn(),
+ getList: vi.fn(),
+ getOne: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ })),
+ },
+ ...data.locals,
+ },
+ cookies: {
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+ },
+ ...data,
+});
+
+// Mock User für Tests
+export const createMockUser = (overrides = {}) => ({
+ id: 'test-user-id',
+ email: 'test@example.com',
+ username: 'testuser',
+ name: 'Test User',
+ verified: true,
+ created: '2024-01-01T00:00:00Z',
+ updated: '2024-01-01T00:00:00Z',
+ ...overrides,
+});
+
+// Mock Link für Tests
+export const createMockLink = (overrides = {}) => ({
+ id: 'test-link-id',
+ user_id: 'test-user-id',
+ original_url: 'https://example.com',
+ short_code: 'test123',
+ title: 'Test Link',
+ is_active: true,
+ click_count: 0,
+ created: '2024-01-01T00:00:00Z',
+ updated: '2024-01-01T00:00:00Z',
+ ...overrides,
+});
+
+// Mock Analytics für Tests
+export const createMockAnalytics = (overrides = {}) => ({
+ id: 'test-analytics-id',
+ link_id: 'test-link-id',
+ ip_address: '127.0.0.1',
+ user_agent: 'Mozilla/5.0',
+ country: 'Germany',
+ device: 'desktop',
+ created: '2024-01-01T00:00:00Z',
+ ...overrides,
+});
+
+// Test Environment Setup
+beforeEach(() => {
+ // Reset all mocks before each test
+ vi.clearAllMocks();
+
+ // Reset DOM (only in browser)
+ if (isBrowser && typeof document !== 'undefined') {
+ document.body.innerHTML = '';
+ }
+
+ // Reset localStorage/sessionStorage (only in browser)
+ if (isBrowser && typeof localStorage !== 'undefined') {
+ localStorage.clear();
+ sessionStorage.clear();
+ }
+});
+
+// Global error handler für Tests (only in Node)
+if (isNode) {
+ process.on('unhandledRejection', (error) => {
+ console.error('Unhandled Promise Rejection in test:', error);
+ });
+}
+
+// Extend expect with custom matchers
+import { expect } from 'vitest';
+
+expect.extend({
+ toBeValidEmail(received: string) {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ const pass = emailRegex.test(received);
+
+ return {
+ pass,
+ message: () =>
+ pass
+ ? `Expected "${received}" not to be a valid email`
+ : `Expected "${received}" to be a valid email`,
+ };
+ },
+
+ toBeValidUsername(received: string) {
+ const usernameRegex = /^[a-z0-9_-]+$/;
+ const pass = usernameRegex.test(received) && received.length >= 3;
+
+ return {
+ pass,
+ message: () =>
+ pass
+ ? `Expected "${received}" not to be a valid username`
+ : `Expected "${received}" to be a valid username (3+ chars, lowercase, numbers, - and _ only)`,
+ };
+ },
+
+ toBeValidShortCode(received: string) {
+ const codeRegex = /^[a-zA-Z0-9_-]+$/;
+ const pass = codeRegex.test(received) && received.length >= 3;
+
+ return {
+ pass,
+ message: () =>
+ pass
+ ? `Expected "${received}" not to be a valid short code`
+ : `Expected "${received}" to be a valid short code`,
+ };
+ },
+});
+
+// Type declarations für custom matchers
+declare module 'vitest' {
+ interface Assertion {
+ toBeValidEmail(): T;
+ toBeValidUsername(): T;
+ toBeValidShortCode(): T;
+ }
+ interface AsymmetricMatchersContaining {
+ toBeValidEmail(): any;
+ toBeValidUsername(): any;
+ toBeValidShortCode(): any;
+ }
+}
diff --git a/apps/uload/apps/web/static/icons/apple-touch-icon.svg b/apps/uload/apps/web/static/icons/apple-touch-icon.svg
new file mode 100644
index 000000000..9443395bf
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/apple-touch-icon.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-128x128.svg b/apps/uload/apps/web/static/icons/icon-128x128.svg
new file mode 100644
index 000000000..95fa8e85d
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-128x128.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-144x144.svg b/apps/uload/apps/web/static/icons/icon-144x144.svg
new file mode 100644
index 000000000..83f528085
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-144x144.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-152x152.svg b/apps/uload/apps/web/static/icons/icon-152x152.svg
new file mode 100644
index 000000000..3396ab655
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-152x152.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-192x192.svg b/apps/uload/apps/web/static/icons/icon-192x192.svg
new file mode 100644
index 000000000..3ef2d1225
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-192x192.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-384x384.svg b/apps/uload/apps/web/static/icons/icon-384x384.svg
new file mode 100644
index 000000000..f82217bf8
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-384x384.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-512x512.svg b/apps/uload/apps/web/static/icons/icon-512x512.svg
new file mode 100644
index 000000000..a0d029fab
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-512x512.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-72x72.svg b/apps/uload/apps/web/static/icons/icon-72x72.svg
new file mode 100644
index 000000000..b98b5ef6c
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-72x72.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-96x96.svg b/apps/uload/apps/web/static/icons/icon-96x96.svg
new file mode 100644
index 000000000..b02cab215
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-96x96.svg
@@ -0,0 +1,4 @@
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-maskable-192x192.svg b/apps/uload/apps/web/static/icons/icon-maskable-192x192.svg
new file mode 100644
index 000000000..6d1d1245e
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-maskable-192x192.svg
@@ -0,0 +1,5 @@
+
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/icons/icon-maskable-512x512.svg b/apps/uload/apps/web/static/icons/icon-maskable-512x512.svg
new file mode 100644
index 000000000..821496f36
--- /dev/null
+++ b/apps/uload/apps/web/static/icons/icon-maskable-512x512.svg
@@ -0,0 +1,5 @@
+
+
+
+ U
+
\ No newline at end of file
diff --git a/apps/uload/apps/web/static/manifest.json b/apps/uload/apps/web/static/manifest.json
new file mode 100644
index 000000000..a8bfecbdc
--- /dev/null
+++ b/apps/uload/apps/web/static/manifest.json
@@ -0,0 +1,132 @@
+{
+ "name": "uLoad - URL Shortener & Link Management",
+ "short_name": "uLoad",
+ "description": "Professional URL shortener with analytics, QR codes, and link management",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1e1b4b",
+ "theme_color": "#3b82f6",
+ "orientation": "portrait-primary",
+ "scope": "/",
+ "categories": ["productivity", "utilities", "business"],
+ "lang": "en",
+ "dir": "ltr",
+ "icons": [
+ {
+ "src": "/favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icons/icon-72x72.svg",
+ "sizes": "72x72",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-96x96.svg",
+ "sizes": "96x96",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-128x128.svg",
+ "sizes": "128x128",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-144x144.svg",
+ "sizes": "144x144",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-152x152.svg",
+ "sizes": "152x152",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-192x192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-384x384.svg",
+ "sizes": "384x384",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512x512.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-maskable-192x192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-maskable-512x512.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Create Link",
+ "short_name": "New Link",
+ "description": "Create a new short link",
+ "url": "/my",
+ "icons": [
+ {
+ "src": "/icons/icon-192x192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml"
+ }
+ ]
+ },
+ {
+ "name": "Analytics",
+ "short_name": "Stats",
+ "description": "View link analytics",
+ "url": "/my/links",
+ "icons": [
+ {
+ "src": "/icons/icon-192x192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml"
+ }
+ ]
+ },
+ {
+ "name": "QR Codes",
+ "short_name": "QR",
+ "description": "Generate QR codes",
+ "url": "/my/links",
+ "icons": [
+ {
+ "src": "/icons/icon-192x192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml"
+ }
+ ]
+ }
+ ],
+ "related_applications": [],
+ "prefer_related_applications": false,
+ "share_target": {
+ "action": "/my",
+ "method": "GET",
+ "params": {
+ "url": "url"
+ }
+ }
+}
diff --git a/apps/uload/apps/web/static/robots.txt b/apps/uload/apps/web/static/robots.txt
new file mode 100644
index 000000000..b6dd6670c
--- /dev/null
+++ b/apps/uload/apps/web/static/robots.txt
@@ -0,0 +1,3 @@
+# allow crawling everything by default
+User-agent: *
+Disallow:
diff --git a/apps/uload/apps/web/static/sw.js b/apps/uload/apps/web/static/sw.js
new file mode 100644
index 000000000..a5ef27fe8
--- /dev/null
+++ b/apps/uload/apps/web/static/sw.js
@@ -0,0 +1,244 @@
+// uLoad Service Worker für PWA-Funktionalität
+const CACHE_NAME = 'uload-v1';
+const OFFLINE_URL = '/offline';
+
+// Assets die gecacht werden sollen
+const CACHE_ASSETS = ['/', '/my', '/offline', '/manifest.json'];
+
+// Install Event - Cache initialisieren
+self.addEventListener('install', (event) => {
+ console.log('Service Worker: Installing');
+
+ event.waitUntil(
+ caches
+ .open(CACHE_NAME)
+ .then((cache) => {
+ console.log('Service Worker: Caching assets');
+ return cache.addAll(CACHE_ASSETS);
+ })
+ .then(() => self.skipWaiting())
+ );
+});
+
+// Activate Event - Alte Caches löschen
+self.addEventListener('activate', (event) => {
+ console.log('Service Worker: Activating');
+
+ event.waitUntil(
+ caches
+ .keys()
+ .then((cacheNames) => {
+ return Promise.all(
+ cacheNames.map((cacheName) => {
+ if (cacheName !== CACHE_NAME) {
+ console.log('Service Worker: Deleting old cache', cacheName);
+ return caches.delete(cacheName);
+ }
+ })
+ );
+ })
+ .then(() => self.clients.claim())
+ );
+});
+
+// Fetch Event - Network-first mit Cache-Fallback
+self.addEventListener('fetch', (event) => {
+ // Nur GET Requests handhaben
+ if (event.request.method !== 'GET') return;
+
+ // Spezielle Behandlung für Navigation Requests
+ if (event.request.mode === 'navigate') {
+ event.respondWith(handleNavigate(event.request));
+ return;
+ }
+
+ // API Requests - Network-first
+ if (event.request.url.includes('/api/')) {
+ event.respondWith(handleApiRequest(event.request));
+ return;
+ }
+
+ // Static Assets - Cache-first
+ if (isStaticAsset(event.request.url)) {
+ event.respondWith(handleStaticAsset(event.request));
+ return;
+ }
+
+ // Default: Network-first
+ event.respondWith(handleDefault(event.request));
+});
+
+// Navigation Requests handhaben
+async function handleNavigate(request) {
+ try {
+ // Versuche Network Request
+ const response = await fetch(request);
+
+ // Bei Erfolg: Response cachen und zurückgeben
+ if (response.status === 200) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+
+ return response;
+ } catch (error) {
+ // Bei Netzwerk-Fehler: Cache prüfen
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ // Offline-Seite anzeigen
+ return caches.match(OFFLINE_URL);
+ }
+}
+
+// API Requests handhaben
+async function handleApiRequest(request) {
+ try {
+ // API Requests immer vom Netzwerk
+ const response = await fetch(request);
+
+ // Bei Erfolg: kurzzeitig cachen (für Read-only Endpoints)
+ if (response.status === 200 && request.method === 'GET') {
+ const cache = await caches.open(CACHE_NAME);
+ // Clone erstellen da Response nur einmal gelesen werden kann
+ cache.put(request, response.clone());
+ }
+
+ return response;
+ } catch (error) {
+ // Bei Offline: gecachte Response zurückgeben (falls vorhanden)
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ // Offline-Antwort für API
+ return new Response(JSON.stringify({ error: 'Offline - Please try again when online' }), {
+ status: 503,
+ statusText: 'Service Unavailable',
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+}
+
+// Static Assets handhaben
+async function handleStaticAsset(request) {
+ // Cache-first für statische Assets
+ const cachedResponse = await caches.match(request);
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+
+ try {
+ const response = await fetch(request);
+
+ // Bei Erfolg: Cache aktualisieren
+ if (response.status === 200) {
+ const cache = await caches.open(CACHE_NAME);
+ cache.put(request, response.clone());
+ }
+
+ return response;
+ } catch (error) {
+ // Bei Offline und nicht im Cache: Default-Response
+ return new Response('Asset not available offline', { status: 404 });
+ }
+}
+
+// Default Request handhaben
+async function handleDefault(request) {
+ try {
+ const response = await fetch(request);
+ return response;
+ } catch (error) {
+ const cachedResponse = await caches.match(request);
+ return cachedResponse || new Response('Offline', { status: 503 });
+ }
+}
+
+// Prüfen ob URL ein statisches Asset ist
+function isStaticAsset(url) {
+ const staticExtensions = [
+ '.css',
+ '.js',
+ '.png',
+ '.jpg',
+ '.jpeg',
+ '.svg',
+ '.ico',
+ '.woff',
+ '.woff2',
+ ];
+ return staticExtensions.some((ext) => url.includes(ext));
+}
+
+// Background Sync für Offline-Aktionen
+self.addEventListener('sync', (event) => {
+ if (event.tag === 'background-sync') {
+ event.waitUntil(handleBackgroundSync());
+ }
+});
+
+async function handleBackgroundSync() {
+ // Hier können Offline-Aktionen synchronisiert werden
+ console.log('Service Worker: Background sync triggered');
+
+ // Beispiel: Gespeicherte Links hochladen
+ try {
+ const pendingLinks = await getPendingLinks();
+ for (const link of pendingLinks) {
+ await syncLink(link);
+ }
+ } catch (error) {
+ console.error('Background sync failed:', error);
+ }
+}
+
+// Push Notifications (für zukünftige Features)
+self.addEventListener('push', (event) => {
+ if (event.data) {
+ const data = event.data.json();
+
+ const options = {
+ body: data.body,
+ icon: '/icons/icon-192x192.png',
+ badge: '/icons/icon-72x72.png',
+ tag: 'uload-notification',
+ renotify: true,
+ actions: [
+ {
+ action: 'view',
+ title: 'View',
+ },
+ {
+ action: 'dismiss',
+ title: 'Dismiss',
+ },
+ ],
+ };
+
+ event.waitUntil(self.registration.showNotification(data.title || 'uLoad', options));
+ }
+});
+
+// Notification Click Event
+self.addEventListener('notificationclick', (event) => {
+ event.notification.close();
+
+ if (event.action === 'view') {
+ event.waitUntil(clients.openWindow('/'));
+ }
+});
+
+// Helper Funktionen für IndexedDB (für Offline-Speicher)
+function getPendingLinks() {
+ // Implementierung für IndexedDB
+ return Promise.resolve([]);
+}
+
+function syncLink(link) {
+ // Implementierung für Link-Synchronisation
+ return Promise.resolve();
+}
diff --git a/apps/uload/apps/web/svelte.config.js b/apps/uload/apps/web/svelte.config.js
new file mode 100644
index 000000000..1ed01e6c1
--- /dev/null
+++ b/apps/uload/apps/web/svelte.config.js
@@ -0,0 +1,47 @@
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+import { mdsvex } from 'mdsvex';
+import rehypeSlug from 'rehype-slug';
+import rehypeAutolinkHeadings from 'rehype-autolink-headings';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/** @type {import('mdsvex').MdsvexOptions} */
+const mdsvexOptions = {
+ extensions: ['.md', '.mdx'],
+ layout: {
+ blog: path.join(__dirname, './src/lib/layouts/BlogLayout.svelte'),
+ _: path.join(__dirname, './src/lib/layouts/DefaultLayout.svelte'),
+ },
+ rehypePlugins: [
+ rehypeSlug,
+ [
+ rehypeAutolinkHeadings,
+ {
+ behavior: 'wrap',
+ properties: {
+ className: 'anchor-link',
+ },
+ },
+ ],
+ ],
+};
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ // Consult https://svelte.dev/docs/kit/integrations
+ // for more information about preprocessors
+ extensions: ['.svelte', '.md', '.mdx'],
+ preprocess: [vitePreprocess(), mdsvex(mdsvexOptions)],
+ kit: {
+ adapter: adapter(),
+ alias: {
+ $paraglide: './src/paraglide',
+ '$paraglide/*': './src/paraglide/*',
+ },
+ },
+};
+
+export default config;
diff --git a/apps/uload/apps/web/tailwind.config.js b/apps/uload/apps/web/tailwind.config.js
new file mode 100644
index 000000000..717ee306b
--- /dev/null
+++ b/apps/uload/apps/web/tailwind.config.js
@@ -0,0 +1,24 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./src/**/*.{html,js,svelte,ts}'],
+ darkMode: 'class',
+ theme: {
+ extend: {
+ colors: {
+ theme: {
+ primary: 'var(--theme-primary)',
+ 'primary-hover': 'var(--theme-primary-hover)',
+ background: 'var(--theme-background)',
+ surface: 'var(--theme-surface)',
+ 'surface-hover': 'var(--theme-surface-hover)',
+ text: 'var(--theme-text)',
+ 'text-muted': 'var(--theme-text-muted)',
+ border: 'var(--theme-border)',
+ accent: 'var(--theme-accent)',
+ 'accent-hover': 'var(--theme-accent-hover)',
+ },
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/apps/uload/apps/web/tsconfig.json b/apps/uload/apps/web/tsconfig.json
new file mode 100644
index 000000000..0b2d8865f
--- /dev/null
+++ b/apps/uload/apps/web/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/apps/uload/apps/web/vite.config.ts b/apps/uload/apps/web/vite.config.ts
new file mode 100644
index 000000000..f8984a1ee
--- /dev/null
+++ b/apps/uload/apps/web/vite.config.ts
@@ -0,0 +1,87 @@
+import tailwindcss from '@tailwindcss/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ resolve: {
+ alias: {
+ $tests: path.resolve('./src/tests'),
+ },
+ },
+ build: {
+ target: 'esnext',
+ rollupOptions: {
+ output: {
+ manualChunks: (id) => {
+ // Vendor chunk für große Bibliotheken
+ if (id.includes('node_modules')) {
+ if (id.includes('pocketbase')) return 'pocketbase';
+ if (id.includes('stripe')) return 'stripe';
+ if (id.includes('lucide-svelte')) return 'icons';
+ if (id.includes('svelte-sonner')) return 'ui';
+ return 'vendor';
+ }
+ // Component chunks für große Komponenten
+ if (id.includes('src/lib/components/cards')) return 'cards';
+ if (id.includes('src/lib/components/links')) return 'links';
+ if (id.includes('src/paraglide')) return 'i18n';
+ },
+ },
+ },
+ },
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./src/tests/setup.ts'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules/**',
+ 'src/tests/**',
+ '**/*.d.ts',
+ 'build/**',
+ '.svelte-kit/**',
+ 'src/paraglide/**',
+ 'src/app.html',
+ ],
+ thresholds: {
+ global: {
+ branches: 70,
+ functions: 70,
+ lines: 70,
+ statements: 70,
+ },
+ },
+ },
+ projects: [
+ {
+ extends: './vite.config.ts',
+ test: {
+ name: 'client',
+ environment: 'browser',
+ browser: {
+ enabled: true,
+ provider: 'playwright',
+ instances: [{ browser: 'chromium' }],
+ },
+ include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
+ exclude: ['src/lib/server/**'],
+ setupFiles: ['./vitest-setup-client.ts', './src/tests/setup.ts'],
+ },
+ },
+ {
+ extends: './vite.config.ts',
+ test: {
+ name: 'server',
+ environment: 'node',
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
+ setupFiles: ['./src/tests/setup.ts'],
+ },
+ },
+ ],
+ },
+});
diff --git a/apps/uload/apps/web/vitest-setup-client.ts b/apps/uload/apps/web/vitest-setup-client.ts
new file mode 100644
index 000000000..570b9f0e1
--- /dev/null
+++ b/apps/uload/apps/web/vitest-setup-client.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/apps/uload/docker-compose.prod.yml b/apps/uload/docker-compose.prod.yml
new file mode 100644
index 000000000..33485d846
--- /dev/null
+++ b/apps/uload/docker-compose.prod.yml
@@ -0,0 +1,98 @@
+# =============================================================================
+# uload Docker Compose - Production (standalone)
+# =============================================================================
+# Use this for manual production deployment without Coolify.
+# For Docker Compose deployments, use docker-compose.coolify.yml instead.
+# =============================================================================
+
+services:
+ # ---------------------------------------------------------------------------
+ # PostgreSQL Database
+ # ---------------------------------------------------------------------------
+ postgres:
+ image: postgres:16-alpine
+ container_name: uload-db-prod
+ restart: always
+ environment:
+ POSTGRES_DB: uload
+ POSTGRES_USER: uload
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ PGDATA: /var/lib/postgresql/data/pgdata
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U uload -d uload"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - uload-network
+
+ # ---------------------------------------------------------------------------
+ # Redis Cache
+ # ---------------------------------------------------------------------------
+ redis:
+ image: redis:7-alpine
+ container_name: uload-redis-prod
+ restart: always
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - uload-network
+
+ # ---------------------------------------------------------------------------
+ # uload Web Application
+ # ---------------------------------------------------------------------------
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: uload-app-prod
+ ports:
+ - '3000:3000'
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+ HOST: 0.0.0.0
+ ORIGIN: ${ORIGIN:-https://ulo.ad}
+ DATABASE_URL: postgresql://uload:${DB_PASSWORD}@postgres:5432/uload
+ REDIS_URL: redis://redis:6379
+ AUTH_SECRET: ${AUTH_SECRET}
+ RESEND_API_KEY: ${RESEND_API_KEY:-}
+ STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
+ STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
+ R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-}
+ R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-}
+ R2_BUCKET_NAME: ${R2_BUCKET_NAME:-}
+ R2_ENDPOINT: ${R2_ENDPOINT:-}
+ PUBLIC_UMAMI_URL: ${PUBLIC_UMAMI_URL:-}
+ PUBLIC_UMAMI_WEBSITE_ID: ${PUBLIC_UMAMI_WEBSITE_ID:-}
+ restart: always
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 40s
+ networks:
+ - uload-network
+
+volumes:
+ postgres_data:
+ driver: local
+ redis_data:
+ driver: local
+
+networks:
+ uload-network:
+ driver: bridge
diff --git a/apps/uload/docker-compose.yml b/apps/uload/docker-compose.yml
new file mode 100644
index 000000000..c8301f04b
--- /dev/null
+++ b/apps/uload/docker-compose.yml
@@ -0,0 +1,93 @@
+# =============================================================================
+# uload Docker Compose - Local Development
+# =============================================================================
+#
+# Usage:
+# docker compose up -d # Start all services
+# docker compose up -d postgres # Start only PostgreSQL
+# docker compose logs -f # Follow logs
+# docker compose down # Stop all services
+# docker compose down -v # Stop and remove volumes
+#
+# Connection strings for local development:
+# DATABASE_URL=postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev
+# REDIS_URL=redis://localhost:6379
+#
+# =============================================================================
+
+services:
+ # ---------------------------------------------------------------------------
+ # PostgreSQL Database
+ # ---------------------------------------------------------------------------
+ postgres:
+ image: postgres:16-alpine
+ container_name: uload-db-dev
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: uload_dev
+ POSTGRES_USER: uload
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-uload_dev_password_123}
+ PGDATA: /var/lib/postgresql/data/pgdata
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U uload -d uload_dev"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - uload-network
+
+ # ---------------------------------------------------------------------------
+ # Redis Cache
+ # ---------------------------------------------------------------------------
+ redis:
+ image: redis:7-alpine
+ container_name: uload-redis-dev
+ restart: unless-stopped
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - uload-network
+
+ # ---------------------------------------------------------------------------
+ # pgAdmin (Database GUI)
+ # ---------------------------------------------------------------------------
+ pgadmin:
+ image: dpage/pgadmin4:latest
+ container_name: uload-pgadmin-dev
+ restart: unless-stopped
+ environment:
+ PGADMIN_DEFAULT_EMAIL: admin@localhost
+ PGADMIN_DEFAULT_PASSWORD: admin
+ PGADMIN_LISTEN_PORT: 5050
+ ports:
+ - "5050:5050"
+ volumes:
+ - pgadmin_data:/var/lib/pgadmin
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - uload-network
+
+volumes:
+ postgres_data:
+ driver: local
+ redis_data:
+ driver: local
+ pgadmin_data:
+ driver: local
+
+networks:
+ uload-network:
+ driver: bridge
diff --git a/apps/uload/package.json b/apps/uload/package.json
new file mode 100644
index 000000000..c0af19827
--- /dev/null
+++ b/apps/uload/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@manacore/uload",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "dev": "turbo run dev"
+ }
+}
diff --git a/apps/uload/packages/uload-database/package.json b/apps/uload/packages/uload-database/package.json
new file mode 100644
index 000000000..a7f077c34
--- /dev/null
+++ b/apps/uload/packages/uload-database/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@manacore/uload-database",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "type-check": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "drizzle-orm": ">=0.38.0",
+ "postgres": ">=3.4.0"
+ },
+ "devDependencies": {
+ "drizzle-orm": "^0.44.7",
+ "postgres": "^3.4.7",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/apps/uload/packages/uload-database/src/index.ts b/apps/uload/packages/uload-database/src/index.ts
new file mode 100644
index 000000000..0afdc9490
--- /dev/null
+++ b/apps/uload/packages/uload-database/src/index.ts
@@ -0,0 +1,45 @@
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import * as schema from './schema';
+
+export * from './schema';
+
+// Re-export drizzle operators used by the backend
+export { eq, and, or, desc, sql, gte, lte, ilike } from 'drizzle-orm';
+
+// Database instance type
+export type Database = ReturnType;
+
+// Infer types for backend services
+export type Link = typeof schema.links.$inferSelect;
+export type NewLink = typeof schema.links.$inferInsert;
+export type Click = typeof schema.clicks.$inferSelect;
+export type NewClick = typeof schema.clicks.$inferInsert;
+
+let db: Database | null = null;
+let client: ReturnType | null = null;
+
+export function getDb(): ReturnType> {
+ if (!db) {
+ const connectionString =
+ process.env.DATABASE_URL ||
+ 'postgresql://uload:uload_dev_password_123@localhost:5432/uload_dev';
+
+ client = postgres(connectionString, {
+ max: 10,
+ idle_timeout: 20,
+ connect_timeout: 10,
+ });
+
+ db = drizzle(client, { schema });
+ }
+ return db!;
+}
+
+export async function closeDb(): Promise {
+ if (client) {
+ await client.end();
+ client = null;
+ db = null;
+ }
+}
diff --git a/apps/uload/packages/uload-database/src/schema.ts b/apps/uload/packages/uload-database/src/schema.ts
new file mode 100644
index 000000000..6513ef255
--- /dev/null
+++ b/apps/uload/packages/uload-database/src/schema.ts
@@ -0,0 +1,186 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ boolean,
+ integer,
+ timestamp,
+ jsonb,
+ index,
+} from 'drizzle-orm/pg-core';
+import { relations } from 'drizzle-orm';
+
+// ============================================
+// Users Table
+// ============================================
+export const users = pgTable(
+ 'users',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ externalAuthId: text('external_auth_id').unique(),
+ email: text('email').unique().notNull(),
+ username: text('username').unique().notNull(),
+ name: text('name'),
+ avatarUrl: text('avatar_url'),
+ bio: text('bio'),
+ location: text('location'),
+ website: text('website'),
+ github: text('github'),
+ twitter: text('twitter'),
+ linkedin: text('linkedin'),
+ instagram: text('instagram'),
+ publicProfile: boolean('public_profile').default(false),
+ showClickStats: boolean('show_click_stats').default(true),
+ emailNotifications: boolean('email_notifications').default(true),
+ defaultExpiry: integer('default_expiry'),
+ profileBackground: text('profile_background'),
+ verified: boolean('verified').default(false),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ emailIdx: index('users_email_idx').on(table.email),
+ usernameIdx: index('users_username_idx').on(table.username),
+ externalAuthIdIdx: index('users_external_auth_id_idx').on(table.externalAuthId),
+ })
+);
+
+// ============================================
+// Accounts Table
+// ============================================
+export const accounts = pgTable(
+ 'accounts',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ owner: uuid('owner')
+ .references(() => users.id)
+ .notNull(),
+ isActive: boolean('is_active').default(true),
+ planType: text('plan_type', { enum: ['free', 'team', 'enterprise'] }).default('free'),
+ settings: jsonb('settings'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ ownerIdx: index('accounts_owner_idx').on(table.owner),
+ })
+);
+
+// ============================================
+// Workspaces Table
+// ============================================
+export const workspaces = pgTable(
+ 'workspaces',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name').notNull(),
+ slug: text('slug').unique().notNull(),
+ type: text('type', { enum: ['personal', 'team'] }).notNull(),
+ owner: uuid('owner')
+ .references(() => users.id)
+ .notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ slugIdx: index('workspaces_slug_idx').on(table.slug),
+ ownerIdx: index('workspaces_owner_idx').on(table.owner),
+ })
+);
+
+// ============================================
+// Links Table
+// ============================================
+export const links = pgTable(
+ 'links',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ shortCode: text('short_code').unique().notNull(),
+ customCode: text('custom_code'),
+ originalUrl: text('original_url').notNull(),
+ title: text('title'),
+ description: text('description'),
+ userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
+ isActive: boolean('is_active').default(true),
+ password: text('password'),
+ maxClicks: integer('max_clicks'),
+ expiresAt: timestamp('expires_at'),
+ clickCount: integer('click_count').default(0),
+ qrCodeUrl: text('qr_code_url'),
+ tags: jsonb('tags').$type(),
+ utmSource: text('utm_source'),
+ utmMedium: text('utm_medium'),
+ utmCampaign: text('utm_campaign'),
+ accountOwner: uuid('account_owner').references(() => accounts.id),
+ workspaceId: uuid('workspace_id').references(() => workspaces.id),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ userIdIdx: index('links_user_id_idx').on(table.userId),
+ shortCodeIdx: index('links_short_code_idx').on(table.shortCode),
+ workspaceIdIdx: index('links_workspace_id_idx').on(table.workspaceId),
+ accountOwnerIdx: index('links_account_owner_idx').on(table.accountOwner),
+ isActiveIdx: index('links_is_active_idx').on(table.isActive),
+ })
+);
+
+// ============================================
+// Clicks Table
+// ============================================
+export const clicks = pgTable(
+ 'clicks',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ linkId: uuid('link_id')
+ .references(() => links.id, { onDelete: 'cascade' })
+ .notNull(),
+ ipHash: text('ip_hash'),
+ userAgent: text('user_agent'),
+ referer: text('referer'),
+ browser: text('browser'),
+ deviceType: text('device_type'),
+ os: text('os'),
+ country: text('country'),
+ city: text('city'),
+ clickedAt: timestamp('clicked_at').defaultNow().notNull(),
+ utmSource: text('utm_source'),
+ utmMedium: text('utm_medium'),
+ utmCampaign: text('utm_campaign'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ },
+ (table) => ({
+ linkIdIdx: index('clicks_link_id_idx').on(table.linkId),
+ clickedAtIdx: index('clicks_clicked_at_idx').on(table.clickedAt),
+ countryIdx: index('clicks_country_idx').on(table.country),
+ })
+);
+
+// ============================================
+// Relations
+// ============================================
+export const usersRelations = relations(users, ({ many }) => ({
+ links: many(links),
+}));
+
+export const linksRelations = relations(links, ({ one, many }) => ({
+ user: one(users, { fields: [links.userId], references: [users.id] }),
+ account: one(accounts, { fields: [links.accountOwner], references: [accounts.id] }),
+ workspace: one(workspaces, { fields: [links.workspaceId], references: [workspaces.id] }),
+ clicks: many(clicks),
+}));
+
+export const clicksRelations = relations(clicks, ({ one }) => ({
+ link: one(links, { fields: [clicks.linkId], references: [links.id] }),
+}));
+
+export const accountsRelations = relations(accounts, ({ one, many }) => ({
+ owner: one(users, { fields: [accounts.owner], references: [users.id] }),
+ links: many(links),
+}));
+
+export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
+ owner: one(users, { fields: [workspaces.owner], references: [users.id] }),
+ links: many(links),
+}));
diff --git a/apps/uload/packages/uload-database/tsconfig.json b/apps/uload/packages/uload-database/tsconfig.json
new file mode 100644
index 000000000..8582244be
--- /dev/null
+++ b/apps/uload/packages/uload-database/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true
+ },
+ "include": ["src"]
+}
diff --git a/package.json b/package.json
index 575929def..e461a7939 100644
--- a/package.json
+++ b/package.json
@@ -107,6 +107,11 @@
"dev:todo:app": "turbo run dev --filter=@todo/web --filter=@todo/backend",
"dev:todo:full": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,backend,web -c blue,magenta,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:todo:server\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\"",
"dev:todo:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:todo:server\" \"pnpm dev:todo:web\"",
+ "dev:uload:web": "pnpm --filter @uload/web dev",
+ "dev:uload:server": "cd apps/uload/apps/server && bun run --watch src/index.ts",
+ "dev:uload:landing": "pnpm --filter @uload/landing dev",
+ "dev:uload:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:uload:server\" \"pnpm dev:uload:web\"",
+ "dev:uload:full": "./scripts/setup-databases.sh uload && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:uload:server\" \"pnpm dev:uload:web\"",
"todo:db:push": "pnpm --filter @todo/backend db:push",
"todo:db:studio": "pnpm --filter @todo/backend db:studio",
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 18fb4c91e..fa50c340b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -202,14 +202,14 @@ importers:
version: link:../../../../packages/shared-landing-ui
astro:
specifier: ^5.16.0
- version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
+ version: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
typescript:
specifier: ^5.9.2
version: 5.9.3
devDependencies:
'@astrojs/tailwind':
specifier: ^6.0.2
- version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ version: 6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
'@tailwindcss/typography':
specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))
@@ -218,13 +218,13 @@ importers:
version: 20.19.25
eslint:
specifier: ^9.0.0
- version: 9.39.1(jiti@2.6.1)
+ version: 9.39.1(jiti@1.21.7)
eslint-config-prettier:
specifier: ^9.1.0
- version: 9.1.2(eslint@9.39.1(jiti@2.6.1))
+ version: 9.1.2(eslint@9.39.1(jiti@1.21.7))
eslint-plugin-astro:
specifier: ^1.0.0
- version: 1.5.0(eslint@9.39.1(jiti@2.6.1))
+ version: 1.5.0(eslint@9.39.1(jiti@1.21.7))
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -484,7 +484,7 @@ importers:
version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo-router:
specifier: ~55.0.5
- version: 55.0.5(fwp4zczusao7cokd7ir5ej7q2m)
+ version: 55.0.5(aimrwth6cdz52vf6mf3gs6ehmm)
expo-status-bar:
specifier: ~55.0.4
version: 55.0.4(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -536,19 +536,19 @@ importers:
version: 19.1.17
'@typescript-eslint/eslint-plugin':
specifier: ^7.7.0
- version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/parser':
specifier: ^7.7.0
- version: 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ version: 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
dotenv:
specifier: ^16.4.7
version: 16.6.1
eslint:
specifier: ^9.39.1
- version: 9.39.1(jiti@1.21.7)
+ version: 9.39.1(jiti@2.6.1)
eslint-config-universe:
specifier: ^12.0.1
- version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3)
+ version: 12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3)
prettier:
specifier: ^3.2.5
version: 3.6.2
@@ -1228,7 +1228,7 @@ importers:
version: 16.1.6(expo@55.0.5)(react@19.2.0)
expo-router:
specifier: ~55.0.5
- version: 55.0.5(pt32crn2332c4slkypkbrcsbnu)
+ version: 55.0.5(yyfvq4xdvwwomf27esym4nbkxe)
expo-secure-store:
specifier: ~55.0.8
version: 55.0.8(expo@55.0.5)
@@ -1694,7 +1694,7 @@ importers:
version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-router:
specifier: ~55.0.5
- version: 55.0.5(fwp4zczusao7cokd7ir5ej7q2m)
+ version: 55.0.5(3s5jslrd73ksoqlrblc4nkbaxq)
expo-secure-store:
specifier: ~55.0.8
version: 55.0.8(expo@55.0.5)
@@ -2015,7 +2015,7 @@ importers:
version: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-router:
specifier: ~55.0.5
- version: 55.0.5(2y7463a4qw2trccupvperml2iy)
+ version: 55.0.5(zafn55q75v7o47cyjbdbemtb7m)
expo-secure-store:
specifier: ~55.0.8
version: 55.0.8(expo@55.0.5)
@@ -2411,7 +2411,7 @@ importers:
version: 55.0.12(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-router:
specifier: ~55.0.5
- version: 55.0.5(xtsqo6xlpeezoeb4r7ibrbxkam)
+ version: 55.0.5(q4hy7bav5ztjlrgmjhpxrzxgqu)
expo-secure-store:
specifier: ~55.0.8
version: 55.0.8(expo@55.0.5)
@@ -3278,7 +3278,7 @@ importers:
version: 55.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))
expo-router:
specifier: ~55.0.5
- version: 55.0.5(ghhebxqzkten3dvea4x4aksnhu)
+ version: 55.0.5(bxgrffxues5ttf7xlcab6p2yce)
expo-secure-store:
specifier: ~55.0.8
version: 55.0.8(expo@55.0.5)
@@ -4778,7 +4778,7 @@ importers:
version: 55.0.9(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))
expo-router:
specifier: ~55.0.5
- version: 55.0.5(zjxxslxkejh45wtx7sxpliuuwu)
+ version: 55.0.5(oinrqag3kg73e5vim3pjq4pqwa)
expo-status-bar:
specifier: ~55.0.4
version: 55.0.4(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -4880,112 +4880,6 @@ importers:
apps/uload: {}
- apps/uload/apps/backend:
- dependencies:
- '@manacore/uload-database':
- specifier: workspace:*
- version: link:../../packages/uload-database
- '@nestjs/axios':
- specifier: ^4.0.1
- version: 4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2)
- '@nestjs/common':
- specifier: ^11.0.1
- version: 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/config':
- specifier: ^4.0.2
- version: 4.0.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
- '@nestjs/core':
- specifier: ^11.0.1
- version: 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/platform-express':
- specifier: ^11.0.1
- version: 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
- '@nestjs/terminus':
- specifier: ^11.0.0
- version: 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- axios:
- specifier: ^1.7.2
- version: 1.14.0
- class-transformer:
- specifier: ^0.5.1
- version: 0.5.1
- class-validator:
- specifier: ^0.14.2
- version: 0.14.3
- ioredis:
- specifier: ^5.4.1
- version: 5.9.2
- joi:
- specifier: ^18.0.1
- version: 18.1.1
- nanoid:
- specifier: ^5.0.7
- version: 5.1.7
- nestjs-cls:
- specifier: ^6.0.1
- version: 6.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- reflect-metadata:
- specifier: ^0.2.2
- version: 0.2.2
- rxjs:
- specifier: ^7.8.1
- version: 7.8.2
- ua-parser-js:
- specifier: ^2.0.0
- version: 2.0.9
- devDependencies:
- '@nestjs/cli':
- specifier: ^11.0.0
- version: 11.0.16(@types/node@22.19.1)(esbuild@0.27.4)
- '@nestjs/schematics':
- specifier: ^11.0.0
- version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
- '@nestjs/testing':
- specifier: ^11.0.1
- version: 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)
- '@types/express':
- specifier: ^5.0.0
- version: 5.0.6
- '@types/jest':
- specifier: ^30.0.0
- version: 30.0.0
- '@types/node':
- specifier: ^22.10.7
- version: 22.19.1
- '@types/supertest':
- specifier: ^6.0.2
- version: 6.0.3
- '@types/ua-parser-js':
- specifier: ^0.7.39
- version: 0.7.39
- jest:
- specifier: ^30.0.0
- version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
- prettier:
- specifier: ^3.4.2
- version: 3.6.2
- source-map-support:
- specifier: ^0.5.21
- version: 0.5.21
- supertest:
- specifier: ^7.0.0
- version: 7.2.2
- ts-jest:
- specifier: ^29.2.5
- version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3)
- ts-loader:
- specifier: ^9.5.2
- version: 9.5.4(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.4))
- ts-node:
- specifier: ^10.9.2
- version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
- tsconfig-paths:
- specifier: ^4.2.0
- version: 4.2.0
- typescript:
- specifier: ^5.9.3
- version: 5.9.3
-
apps/uload/apps/landing:
dependencies:
'@astrojs/check':
@@ -5017,44 +4911,51 @@ importers:
specifier: ^5.7.2
version: 5.9.3
+ apps/uload/apps/server:
+ dependencies:
+ '@manacore/uload-database':
+ specifier: workspace:*
+ version: link:../../packages/uload-database
+ drizzle-orm:
+ specifier: ^0.44.7
+ version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7)
+ hono:
+ specifier: ^4.7.0
+ version: 4.12.9
+ jose:
+ specifier: ^6.1.2
+ version: 6.1.2
+ postgres:
+ specifier: ^3.4.7
+ version: 3.4.7
+ devDependencies:
+ '@types/bun':
+ specifier: ^1.2.0
+ version: 1.3.11
+ typescript:
+ specifier: ^5.0.0
+ version: 5.9.3
+
apps/uload/apps/web:
dependencies:
- '@aws-sdk/client-s3':
- specifier: ^3.934.0
- version: 3.940.0
- '@aws-sdk/s3-request-presigner':
- specifier: ^3.934.0
- version: 3.940.0
+ '@manacore/local-store':
+ specifier: workspace:*
+ version: link:../../../../packages/local-store
'@manacore/shared-auth-ui':
specifier: workspace:*
version: link:../../../../packages/shared-auth-ui
'@manacore/shared-branding':
specifier: workspace:*
version: link:../../../../packages/shared-branding
- drizzle-orm:
- specifier: ^0.44.7
- version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7)
- ioredis:
- specifier: ^5.7.0
- version: 5.9.2
+ '@manacore/shared-ui':
+ specifier: workspace:*
+ version: link:../../../../packages/shared-ui
isomorphic-dompurify:
specifier: ^2.26.0
version: 2.36.0(@noble/hashes@2.0.1)
lucide-svelte:
specifier: ^0.539.0
version: 0.539.0(svelte@5.44.0)
- pocketbase:
- specifier: ^0.26.2
- version: 0.26.8
- postgres:
- specifier: ^3.4.7
- version: 3.4.7
- resend:
- specifier: ^6.5.1
- version: 6.9.4
- stripe:
- specifier: ^18.4.0
- version: 18.5.0(@types/node@24.10.1)
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.44.0)
@@ -5172,14 +5073,13 @@ importers:
version: 4.1.13
apps/uload/packages/uload-database:
- dependencies:
+ devDependencies:
drizzle-orm:
specifier: ^0.44.7
version: 0.44.7(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7)
postgres:
specifier: ^3.4.7
version: 3.4.7
- devDependencies:
typescript:
specifier: ^5.0.0
version: 5.9.3
@@ -6398,46 +6298,15 @@ packages:
chokidar:
optional: true
- '@angular-devkit/core@19.2.17':
- resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==}
- engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
- peerDependencies:
- chokidar: ^4.0.0
- peerDependenciesMeta:
- chokidar:
- optional: true
-
- '@angular-devkit/core@19.2.19':
- resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==}
- engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
- peerDependencies:
- chokidar: ^4.0.0
- peerDependenciesMeta:
- chokidar:
- optional: true
-
'@angular-devkit/schematics-cli@17.3.11':
resolution: {integrity: sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==}
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
hasBin: true
- '@angular-devkit/schematics-cli@19.2.19':
- resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==}
- engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
- hasBin: true
-
'@angular-devkit/schematics@17.3.11':
resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==}
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
- '@angular-devkit/schematics@19.2.17':
- resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==}
- engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
-
- '@angular-devkit/schematics@19.2.19':
- resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==}
- engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
-
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
@@ -9037,26 +8906,6 @@ packages:
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
- '@hapi/address@5.1.1':
- resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
- engines: {node: '>=14.0.0'}
-
- '@hapi/formula@3.0.2':
- resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==}
-
- '@hapi/hoek@11.0.7':
- resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==}
-
- '@hapi/pinpoint@2.0.1':
- resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==}
-
- '@hapi/tlds@1.1.6':
- resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==}
- engines: {node: '>=14.0.0'}
-
- '@hapi/topo@6.0.2':
- resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==}
-
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -9345,149 +9194,6 @@ packages:
cpu: [x64]
os: [win32]
- '@inquirer/ansi@1.0.2':
- resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
- engines: {node: '>=18'}
-
- '@inquirer/checkbox@4.3.2':
- resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/confirm@5.1.21':
- resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/core@10.3.2':
- resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/editor@4.2.23':
- resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/expand@4.0.23':
- resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/external-editor@1.0.3':
- resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/figures@1.0.15':
- resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
- engines: {node: '>=18'}
-
- '@inquirer/input@4.3.1':
- resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/number@3.0.23':
- resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/password@4.0.23':
- resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/prompts@7.10.1':
- resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/prompts@7.3.2':
- resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/rawlist@4.1.11':
- resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/search@3.2.2':
- resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/select@4.4.2':
- resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
- '@inquirer/type@3.0.10':
- resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
- engines: {node: '>=18'}
- peerDependencies:
- '@types/node': '>=18'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
'@ioredis/commands@1.5.0':
resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==}
@@ -9742,13 +9448,6 @@ packages:
peerDependencies:
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0
- '@nestjs/axios@4.0.1':
- resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==}
- peerDependencies:
- '@nestjs/common': ^10.0.0 || ^11.0.0
- axios: ^1.3.1
- rxjs: ^7.0.0
-
'@nestjs/cli@10.4.9':
resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==}
engines: {node: '>= 16.14'}
@@ -9762,19 +9461,6 @@ packages:
'@swc/core':
optional: true
- '@nestjs/cli@11.0.16':
- resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
- engines: {node: '>= 20.11'}
- hasBin: true
- peerDependencies:
- '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0
- '@swc/core': ^1.3.62
- peerDependenciesMeta:
- '@swc/cli':
- optional: true
- '@swc/core':
- optional: true
-
'@nestjs/common@10.4.20':
resolution: {integrity: sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==}
peerDependencies:
@@ -9807,12 +9493,6 @@ packages:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
rxjs: ^7.1.0
- '@nestjs/config@4.0.3':
- resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==}
- peerDependencies:
- '@nestjs/common': ^10.0.0 || ^11.0.0
- rxjs: ^7.1.0
-
'@nestjs/core@10.4.20':
resolution: {integrity: sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==}
peerDependencies:
@@ -9830,36 +9510,12 @@ packages:
'@nestjs/websockets':
optional: true
- '@nestjs/core@11.1.17':
- resolution: {integrity: sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==}
- engines: {node: '>= 20'}
- peerDependencies:
- '@nestjs/common': ^11.0.0
- '@nestjs/microservices': ^11.0.0
- '@nestjs/platform-express': ^11.0.0
- '@nestjs/websockets': ^11.0.0
- reflect-metadata: ^0.1.12 || ^0.2.0
- rxjs: ^7.1.0
- peerDependenciesMeta:
- '@nestjs/microservices':
- optional: true
- '@nestjs/platform-express':
- optional: true
- '@nestjs/websockets':
- optional: true
-
'@nestjs/platform-express@10.4.20':
resolution: {integrity: sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==}
peerDependencies:
'@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0
- '@nestjs/platform-express@11.1.17':
- resolution: {integrity: sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==}
- peerDependencies:
- '@nestjs/common': ^11.0.0
- '@nestjs/core': ^11.0.0
-
'@nestjs/platform-socket.io@10.4.20':
resolution: {integrity: sha512-8wqJ7kJnvRC6T1o1U3NNnuzjaMJU43R4hvzKKba7GSdMN6j2Jfzz/vq5gHDx9xbXOAmfsc9bvaIiZegXxvHoJA==}
peerDependencies:
@@ -9872,59 +9528,6 @@ packages:
peerDependencies:
typescript: '>=4.8.2'
- '@nestjs/schematics@11.0.9':
- resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==}
- peerDependencies:
- typescript: '>=4.8.2'
-
- '@nestjs/terminus@11.1.1':
- resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==}
- peerDependencies:
- '@grpc/grpc-js': '*'
- '@grpc/proto-loader': '*'
- '@mikro-orm/core': '*'
- '@mikro-orm/nestjs': '*'
- '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0
- '@nestjs/common': ^10.0.0 || ^11.0.0
- '@nestjs/core': ^10.0.0 || ^11.0.0
- '@nestjs/microservices': ^10.0.0 || ^11.0.0
- '@nestjs/mongoose': ^11.0.0
- '@nestjs/sequelize': ^10.0.0 || ^11.0.0
- '@nestjs/typeorm': ^10.0.0 || ^11.0.0
- '@prisma/client': '*'
- mongoose: '*'
- reflect-metadata: 0.1.x || 0.2.x
- rxjs: 7.x
- sequelize: '*'
- typeorm: '*'
- peerDependenciesMeta:
- '@grpc/grpc-js':
- optional: true
- '@grpc/proto-loader':
- optional: true
- '@mikro-orm/core':
- optional: true
- '@mikro-orm/nestjs':
- optional: true
- '@nestjs/axios':
- optional: true
- '@nestjs/microservices':
- optional: true
- '@nestjs/mongoose':
- optional: true
- '@nestjs/sequelize':
- optional: true
- '@nestjs/typeorm':
- optional: true
- '@prisma/client':
- optional: true
- mongoose:
- optional: true
- sequelize:
- optional: true
- typeorm:
- optional: true
-
'@nestjs/testing@10.4.20':
resolution: {integrity: sha512-nMkRDukDKskdPruM6EsgMq7yJua+CPZM6I6FrLP8yXw8BiVSPv9Nm0CtcGGwt3kgZF9hfxKjGqLjsvVBsv6Vfw==}
peerDependencies:
@@ -9938,19 +9541,6 @@ packages:
'@nestjs/platform-express':
optional: true
- '@nestjs/testing@11.1.17':
- resolution: {integrity: sha512-lNffw+z+2USewmw4W0tsK+Rq94A2N4PiHbcqoRUu5y8fnqxQeIWGHhjo5BFCqj7eivqJBhT7WdRydxVq4rAHzg==}
- peerDependencies:
- '@nestjs/common': ^11.0.0
- '@nestjs/core': ^11.0.0
- '@nestjs/microservices': ^11.0.0
- '@nestjs/platform-express': ^11.0.0
- peerDependenciesMeta:
- '@nestjs/microservices':
- optional: true
- '@nestjs/platform-express':
- optional: true
-
'@nestjs/websockets@10.4.20':
resolution: {integrity: sha512-tafsPPvQfAXc+cfxvuRDzS5V+Ixg8uVJq8xSocU24yVl/Xp6ajmhqiGiaVjYOX8mXY0NV836QwEZxHF7WvKHSw==}
peerDependencies:
@@ -9983,10 +9573,6 @@ packages:
resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==}
engines: {node: '>= 20.19.0'}
- '@noble/hashes@1.8.0':
- resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
- engines: {node: ^14.21.3 || >=16}
-
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
@@ -10007,11 +9593,6 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
- '@nuxt/opencollective@0.4.1':
- resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
- engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
- hasBin: true
-
'@nuxtjs/opencollective@0.3.2':
resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==}
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
@@ -10269,9 +9850,6 @@ packages:
cpu: [x64]
os: [win32]
- '@paralleldrive/cuid2@2.3.1':
- resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
-
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
@@ -11620,9 +11198,6 @@ packages:
resolution: {integrity: sha512-TBbTTWhiI6v2CT7J1hij5shx+RGL4iICprVGYhO+LKv5Nbn3NeJPWCY8kMKL5vA6b33NeWkBk4dy6RFbNh3jBw==}
hasBin: true
- '@stablelib/base64@1.0.1':
- resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
-
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -11937,9 +11512,6 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
- '@types/cookiejar@2.1.5':
- resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
-
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
@@ -12105,9 +11677,6 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
- '@types/jest@30.0.0':
- resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
-
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
@@ -12129,9 +11698,6 @@ packages:
'@types/mdx@2.0.13':
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
- '@types/methods@1.1.4':
- resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
-
'@types/mime-types@2.1.4':
resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
@@ -12241,12 +11807,6 @@ packages:
'@types/suncalc@1.9.2':
resolution: {integrity: sha512-ATAGBHHfA1TlE2tjfidLyTcysjoT2JHHEAmWRULh73SU9UTn++j5fqHEW16X6Y/2Li87jEQXzgu4R/OOdlDqzw==}
- '@types/superagent@8.1.9':
- resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
-
- '@types/supertest@6.0.3':
- resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
-
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -12259,9 +11819,6 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
- '@types/ua-parser-js@0.7.39':
- resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==}
-
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -13009,21 +12566,11 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
- accepts@2.0.0:
- resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
- engines: {node: '>= 0.6'}
-
acorn-import-attributes@1.9.5:
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
peerDependencies:
acorn: ^8
- acorn-import-phases@1.0.4:
- resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==}
- engines: {node: '>=10.13.0'}
- peerDependencies:
- acorn: ^8.14.0
-
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -13067,14 +12614,6 @@ packages:
ajv:
optional: true
- ajv-formats@3.0.1:
- resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
- peerDependencies:
- ajv: ^8.0.0
- peerDependenciesMeta:
- ajv:
- optional: true
-
ajv-keywords@3.5.2:
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
peerDependencies:
@@ -13310,9 +12849,6 @@ packages:
await-lock@2.2.2:
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
- axios@1.14.0:
- resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
-
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -13457,11 +12993,6 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
- baseline-browser-mapping@2.10.12:
- resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
baseline-browser-mapping@2.8.31:
resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==}
hasBin: true
@@ -13550,20 +13081,12 @@ packages:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
- body-parser@2.2.2:
- resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
- engines: {node: '>=18'}
-
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
bowser@2.13.1:
resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==}
- boxen@5.1.2:
- resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==}
- engines: {node: '>=10'}
-
boxen@8.0.1:
resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==}
engines: {node: '>=18'}
@@ -13604,15 +13127,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
- browserslist@4.28.1:
- resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
- engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
- hasBin: true
-
- bs-logger@0.2.6:
- resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==}
- engines: {node: '>= 6'}
-
bs58@6.0.0:
resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
@@ -13709,9 +13223,6 @@ packages:
caniuse-lite@1.0.30001757:
resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==}
- caniuse-lite@1.0.30001781:
- resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
-
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -13762,13 +13273,6 @@ packages:
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
- chardet@2.1.1:
- resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
-
- check-disk-space@3.4.0:
- resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==}
- engines: {node: '>=16'}
-
check-error@1.0.3:
resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
@@ -13837,10 +13341,6 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
- cli-boxes@2.2.1:
- resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
- engines: {node: '>=6'}
-
cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'}
@@ -13988,9 +13488,6 @@ packages:
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
- component-emitter@1.3.1:
- resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
-
compressible@2.0.18:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
@@ -14032,10 +13529,6 @@ packages:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
- content-disposition@1.0.1:
- resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
- engines: {node: '>=18'}
-
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
@@ -14049,10 +13542,6 @@ packages:
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
- cookie-signature@1.2.2:
- resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
- engines: {node: '>=6.6.0'}
-
cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
@@ -14073,9 +13562,6 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
- cookiejar@2.1.4:
- resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
-
core-js-compat@3.47.0:
resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==}
@@ -14471,9 +13957,6 @@ packages:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
- detect-europe-js@0.1.2:
- resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
-
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
@@ -14509,9 +13992,6 @@ packages:
dexie@4.4.1:
resolution: {integrity: sha512-4Xec5+yrS+TgyFAnMrneFOt/QG8sD3FxlkUVpfypui3SriRN80UN0SZBWmkNAY7ulfKgk0ilvv7M6pBURprdgA==}
- dezalgo@1.0.4:
- resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
-
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
@@ -14597,10 +14077,6 @@ packages:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
- dotenv-expand@12.0.3:
- resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
- engines: {node: '>=12'}
-
dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
@@ -14613,10 +14089,6 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
- dotenv@17.2.3:
- resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
- engines: {node: '>=12'}
-
drizzle-kit@0.28.1:
resolution: {integrity: sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ==}
hasBin: true
@@ -15025,9 +14497,6 @@ packages:
electron-to-chromium@1.5.260:
resolution: {integrity: sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==}
- electron-to-chromium@1.5.328:
- resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==}
-
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
@@ -16136,10 +15605,6 @@ packages:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'}
- express@5.2.1:
- resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
- engines: {node: '>= 18'}
-
expressive-code@0.40.2:
resolution: {integrity: sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw==}
@@ -16184,9 +15649,6 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
- fast-sha256@1.3.0:
- resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
-
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
@@ -16276,10 +15738,6 @@ packages:
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
engines: {node: '>= 0.8'}
- finalhandler@2.1.1:
- resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
- engines: {node: '>= 18.0.0'}
-
find-babel-config@2.1.2:
resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==}
@@ -16319,15 +15777,6 @@ packages:
flow-enums-runtime@0.0.6:
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
- follow-redirects@1.15.11:
- resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
- engines: {node: '>=4.0'}
- peerDependencies:
- debug: '*'
- peerDependenciesMeta:
- debug:
- optional: true
-
fontace@0.3.1:
resolution: {integrity: sha512-9f5g4feWT1jWT8+SbL85aLIRLIXUaDygaM2xPXRmzPYxrOMNok79Lr3FGJoKVNKibE0WCunNiEVG2mwuE+2qEg==}
@@ -16359,21 +15808,10 @@ packages:
typescript: '>3.6.0'
webpack: ^5.11.0
- fork-ts-checker-webpack-plugin@9.1.0:
- resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==}
- engines: {node: '>=14.21.3'}
- peerDependencies:
- typescript: '>3.6.0'
- webpack: ^5.11.0
-
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
- formidable@3.5.4:
- resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
- engines: {node: '>=14.0.0'}
-
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
@@ -16392,10 +15830,6 @@ packages:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
- fresh@2.0.0:
- resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
- engines: {node: '>= 0.8'}
-
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -16600,11 +16034,6 @@ packages:
h3@1.15.4:
resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==}
- handlebars@4.7.9:
- resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
- engines: {node: '>=0.4.7'}
- hasBin: true
-
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -16771,10 +16200,6 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
- http-errors@2.0.1:
- resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
- engines: {node: '>= 0.8'}
-
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -16830,10 +16255,6 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
- iconv-lite@0.7.2:
- resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
- engines: {node: '>=0.10.0'}
-
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
@@ -17087,9 +16508,6 @@ packages:
is-promise@2.2.2:
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
- is-promise@4.0.0:
- resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
-
is-reference@1.2.1:
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
@@ -17112,9 +16530,6 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
- is-standalone-pwa@0.1.1:
- resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
-
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -17495,10 +16910,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
- joi@18.1.1:
- resolution: {integrity: sha512-pJkBiPtNo+o0h19LfSvUN46Y5zY+ck99AtHwch9n2HqVLNRgP0ZMyIH8FRMoP+HV8hy/+AG99dXFfwpf83iZfQ==}
- engines: {node: '>= 20'}
-
jose@6.1.2:
resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==}
@@ -17906,9 +17317,6 @@ packages:
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
- lodash.memoize@4.1.2:
- resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
-
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -17986,9 +17394,6 @@ packages:
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
- magic-string@0.30.17:
- resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
-
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -18126,10 +17531,6 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
- media-typer@1.1.0:
- resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
- engines: {node: '>= 0.8'}
-
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
@@ -18147,10 +17548,6 @@ packages:
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
- merge-descriptors@2.0.0:
- resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
- engines: {node: '>=18'}
-
merge-options@3.0.4:
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
engines: {node: '>=10'}
@@ -18406,20 +17803,11 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
- mime-types@3.0.2:
- resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
- engines: {node: '>=18'}
-
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
- mime@2.6.0:
- resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
- engines: {node: '>=4.0.0'}
- hasBin: true
-
mime@3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
@@ -18542,10 +17930,6 @@ packages:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'}
- multer@2.1.1:
- resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==}
- engines: {node: '>= 10.16.0'}
-
multitars@0.2.4:
resolution: {integrity: sha512-XgLbg1HHchFauMCQPRwMj6MSyDd5koPlTA1hM3rUFkeXzGpjU/I9fP3to7yrObE9jcN8ChIOQGrM0tV0kUZaKg==}
@@ -18556,10 +17940,6 @@ packages:
resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
- mute-stream@2.0.0:
- resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
- engines: {node: ^18.17.0 || >=20.5.0}
-
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -18572,11 +17952,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@5.1.7:
- resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==}
- engines: {node: ^18 || >=20}
- hasBin: true
-
nanostores@1.1.0:
resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==}
engines: {node: ^20.0.0 || >=22.0.0}
@@ -18603,10 +17978,6 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
- negotiator@1.0.0:
- resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
- engines: {node: '>= 0.6'}
-
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -18617,15 +17988,6 @@ packages:
nested-error-stacks@2.0.1:
resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==}
- nestjs-cls@6.2.0:
- resolution: {integrity: sha512-b2Remha7gV5gId3ezjr2tupjqqgYK7/JqjqX6oZ0ZIDFATUggKH1/32+ul2lOe7FepnHasDONDoePuWEE64cug==}
- engines: {node: '>=18'}
- peerDependencies:
- '@nestjs/common': '>= 10 < 12'
- '@nestjs/core': '>= 10 < 12'
- reflect-metadata: '*'
- rxjs: '>= 7'
-
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
@@ -18984,9 +18346,6 @@ packages:
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
- path-to-regexp@8.3.0:
- resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
-
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@@ -19052,10 +18411,6 @@ packages:
resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==}
engines: {node: '>=12'}
- picomatch@4.0.2:
- resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
- engines: {node: '>=12'}
-
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
@@ -19116,16 +18471,10 @@ packages:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
- pocketbase@0.26.8:
- resolution: {integrity: sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==}
-
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
- postal-mime@2.7.3:
- resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==}
-
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -19445,10 +18794,6 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
- proxy-from-env@2.1.0:
- resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
- engines: {node: '>=10'}
-
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
@@ -19479,10 +18824,6 @@ packages:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'}
- qs@6.15.0:
- resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
- engines: {node: '>=0.6'}
-
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -19510,10 +18851,6 @@ packages:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
engines: {node: '>= 0.8'}
- raw-body@3.0.2:
- resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
- engines: {node: '>= 0.10'}
-
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@@ -20076,15 +19413,6 @@ packages:
reselect@4.1.8:
resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
- resend@6.9.4:
- resolution: {integrity: sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==}
- engines: {node: '>=20'}
- peerDependencies:
- '@react-email/render': '*'
- peerDependenciesMeta:
- '@react-email/render':
- optional: true
-
resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
@@ -20178,10 +19506,6 @@ packages:
rou3@0.5.1:
resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==}
- router@2.2.0:
- resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
- engines: {node: '>= 18'}
-
rrule@2.8.1:
resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
@@ -20306,10 +19630,6 @@ packages:
resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==}
engines: {node: '>= 0.8.0'}
- send@1.2.1:
- resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
- engines: {node: '>= 18'}
-
serialize-error@2.1.0:
resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==}
engines: {node: '>=0.10.0'}
@@ -20321,10 +19641,6 @@ packages:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
- serve-static@2.2.1:
- resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
- engines: {node: '>= 18'}
-
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
@@ -20542,9 +19858,6 @@ packages:
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
- standardwebhooks@1.0.0:
- resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
-
statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
@@ -20553,10 +19866,6 @@ packages:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
- statuses@2.0.2:
- resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
- engines: {node: '>= 0.8'}
-
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -20706,15 +20015,6 @@ packages:
resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==}
engines: {node: '>=12.*'}
- stripe@18.5.0:
- resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==}
- engines: {node: '>=12.*'}
- peerDependencies:
- '@types/node': '>=12.x.x'
- peerDependenciesMeta:
- '@types/node':
- optional: true
-
strnum@1.1.2:
resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==}
@@ -20753,14 +20053,6 @@ packages:
suncalc@1.9.0:
resolution: {integrity: sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A==}
- superagent@10.3.0:
- resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
- engines: {node: '>=14.18.0'}
-
- supertest@7.2.2:
- resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==}
- engines: {node: '>=14.18.0'}
-
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
@@ -20842,9 +20134,6 @@ packages:
engines: {node: '>=16'}
hasBin: true
- svix@1.86.0:
- resolution: {integrity: sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==}
-
symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
@@ -20904,22 +20193,6 @@ packages:
uglify-js:
optional: true
- terser-webpack-plugin@5.4.0:
- resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
- engines: {node: '>= 10.13.0'}
- peerDependencies:
- '@swc/core': '*'
- esbuild: '*'
- uglify-js: '*'
- webpack: ^5.1.0
- peerDependenciesMeta:
- '@swc/core':
- optional: true
- esbuild:
- optional: true
- uglify-js:
- optional: true
-
terser@5.44.1:
resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
engines: {node: '>=10'}
@@ -21119,40 +20392,6 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
- ts-jest@29.4.6:
- resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==}
- engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
- hasBin: true
- peerDependencies:
- '@babel/core': '>=7.0.0-beta.0 <8'
- '@jest/transform': ^29.0.0 || ^30.0.0
- '@jest/types': ^29.0.0 || ^30.0.0
- babel-jest: ^29.0.0 || ^30.0.0
- esbuild: '*'
- jest: ^29.0.0 || ^30.0.0
- jest-util: ^29.0.0 || ^30.0.0
- typescript: '>=4.3 <6'
- peerDependenciesMeta:
- '@babel/core':
- optional: true
- '@jest/transform':
- optional: true
- '@jest/types':
- optional: true
- babel-jest:
- optional: true
- esbuild:
- optional: true
- jest-util:
- optional: true
-
- ts-loader@9.5.4:
- resolution: {integrity: sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==}
- engines: {node: '>=12.0.0'}
- peerDependencies:
- typescript: '*'
- webpack: ^5.0.0
-
ts-node@10.9.2:
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
hasBin: true
@@ -21298,10 +20537,6 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
- type-is@2.0.1:
- resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
- engines: {node: '>= 0.6'}
-
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
@@ -21364,9 +20599,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- ua-is-frozen@0.1.2:
- resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
-
ua-parser-js@0.7.41:
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
hasBin: true
@@ -21375,10 +20607,6 @@ packages:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
- ua-parser-js@2.0.9:
- resolution: {integrity: sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==}
- hasBin: true
-
uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
@@ -21388,11 +20616,6 @@ packages:
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
- uglify-js@3.19.3:
- resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
- engines: {node: '>=0.8.0'}
- hasBin: true
-
uid@2.0.2:
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
engines: {node: '>=8'}
@@ -21664,12 +20887,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
- update-browserslist-db@1.2.3:
- resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
- hasBin: true
- peerDependencies:
- browserslist: '>= 4.21.0'
-
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -21716,10 +20933,6 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
- uuid@10.0.0:
- resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
- hasBin: true
-
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
@@ -22290,16 +21503,6 @@ packages:
resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==}
engines: {node: '>=10.13.0'}
- webpack@5.104.1:
- resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==}
- engines: {node: '>=10.13.0'}
- hasBin: true
- peerDependencies:
- webpack-cli: '*'
- peerDependenciesMeta:
- webpack-cli:
- optional: true
-
webpack@5.97.1:
resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==}
engines: {node: '>=10.13.0'}
@@ -22385,10 +21588,6 @@ packages:
engines: {node: '>=8'}
hasBin: true
- widest-line@3.1.0:
- resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
- engines: {node: '>=8'}
-
widest-line@5.0.0:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'}
@@ -22400,9 +21599,6 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
- wordwrap@1.0.0:
- resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
-
workbox-background-sync@7.4.0:
resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==}
@@ -22657,10 +21853,6 @@ packages:
resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==}
engines: {node: '>=18.19'}
- yoctocolors-cjs@2.1.3:
- resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
- engines: {node: '>=18'}
-
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
@@ -22761,28 +21953,6 @@ snapshots:
optionalDependencies:
chokidar: 3.6.0
- '@angular-devkit/core@19.2.17(chokidar@4.0.3)':
- dependencies:
- ajv: 8.17.1
- ajv-formats: 3.0.1(ajv@8.17.1)
- jsonc-parser: 3.3.1
- picomatch: 4.0.2
- rxjs: 7.8.1
- source-map: 0.7.4
- optionalDependencies:
- chokidar: 4.0.3
-
- '@angular-devkit/core@19.2.19(chokidar@4.0.3)':
- dependencies:
- ajv: 8.17.1
- ajv-formats: 3.0.1(ajv@8.17.1)
- jsonc-parser: 3.3.1
- picomatch: 4.0.2
- rxjs: 7.8.1
- source-map: 0.7.4
- optionalDependencies:
- chokidar: 4.0.3
-
'@angular-devkit/schematics-cli@17.3.11(chokidar@3.6.0)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -22794,18 +21964,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
- '@angular-devkit/schematics-cli@19.2.19(@types/node@22.19.1)(chokidar@4.0.3)':
- dependencies:
- '@angular-devkit/core': 19.2.19(chokidar@4.0.3)
- '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3)
- '@inquirer/prompts': 7.3.2(@types/node@22.19.1)
- ansi-colors: 4.1.3
- symbol-observable: 4.0.0
- yargs-parser: 21.1.1
- transitivePeerDependencies:
- - '@types/node'
- - chokidar
-
'@angular-devkit/schematics@17.3.11(chokidar@3.6.0)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -22816,26 +21974,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
- '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)':
- dependencies:
- '@angular-devkit/core': 19.2.17(chokidar@4.0.3)
- jsonc-parser: 3.3.1
- magic-string: 0.30.17
- ora: 5.4.1
- rxjs: 7.8.1
- transitivePeerDependencies:
- - chokidar
-
- '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)':
- dependencies:
- '@angular-devkit/core': 19.2.19(chokidar@4.0.3)
- jsonc-parser: 3.3.1
- magic-string: 0.30.17
- ora: 5.4.1
- rxjs: 7.8.1
- transitivePeerDependencies:
- - chokidar
-
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.5.0
@@ -23120,6 +22258,16 @@ snapshots:
transitivePeerDependencies:
- ts-node
+ '@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))':
+ dependencies:
+ astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
+ autoprefixer: 10.4.22(postcss@8.5.6)
+ postcss: 8.5.6
+ postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
+ tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.1)
+ transitivePeerDependencies:
+ - ts-node
+
'@astrojs/tailwind@6.0.2(astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.1))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))':
dependencies:
astro: 5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1)
@@ -25429,7 +24577,7 @@ snapshots:
wrap-ansi: 7.0.0
ws: 8.18.3
optionalDependencies:
- expo-router: 55.0.5(qwxmdxiornnsbyvrtivw4g2joq)
+ expo-router: 55.0.5(dfjkn535zxizfdfnhu5vc4kgbu)
react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -25499,7 +24647,7 @@ snapshots:
ws: 8.18.3
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.5(fwp4zczusao7cokd7ir5ej7q2m)
+ expo-router: 55.0.5(aimrwth6cdz52vf6mf3gs6ehmm)
react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -25575,7 +24723,7 @@ snapshots:
ws: 8.18.3
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.5(ghhebxqzkten3dvea4x4aksnhu)
+ expo-router: 55.0.5(bxgrffxues5ttf7xlcab6p2yce)
react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -25651,7 +24799,7 @@ snapshots:
ws: 8.18.3
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.5(zjxxslxkejh45wtx7sxpliuuwu)
+ expo-router: 55.0.5(3s5jslrd73ksoqlrblc4nkbaxq)
react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -25727,7 +24875,7 @@ snapshots:
ws: 8.18.3
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.5(xtsqo6xlpeezoeb4r7ibrbxkam)
+ expo-router: 55.0.5(q4hy7bav5ztjlrgmjhpxrzxgqu)
react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -25803,7 +24951,7 @@ snapshots:
ws: 8.18.3
zod: 3.25.76
optionalDependencies:
- expo-router: 55.0.5(c6fvoo4nweiab5jjqzamvkmewm)
+ expo-router: 55.0.5(fleqby3xtsmzebby5l5omzr6ue)
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -25817,7 +24965,6 @@ snapshots:
- supports-color
- typescript
- utf-8-validate
- optional: true
'@expo/code-signing-certificates@0.0.5':
dependencies:
@@ -25972,7 +25119,6 @@ snapshots:
optionalDependencies:
react: 19.2.4
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- optional: true
'@expo/dom-webview@55.0.3(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -25998,7 +25144,6 @@ snapshots:
expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: 19.2.4
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- optional: true
'@expo/env@2.0.7':
dependencies:
@@ -26125,7 +25270,6 @@ snapshots:
react: 19.2.4
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
stacktrace-parser: 0.1.11
- optional: true
'@expo/mcp-tunnel@0.1.0':
dependencies:
@@ -26246,7 +25390,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -26427,7 +25571,7 @@ snapshots:
'@expo/json-file': 10.0.12
'@react-native/normalize-colors': 0.83.2
debug: 4.4.3
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.3
xml2js: 0.6.0
@@ -26475,7 +25619,7 @@ snapshots:
react: 19.2.0
optionalDependencies:
'@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- expo-router: 55.0.5(fwp4zczusao7cokd7ir5ej7q2m)
+ expo-router: 55.0.5(aimrwth6cdz52vf6mf3gs6ehmm)
react-dom: 19.2.0(react@19.2.0)
transitivePeerDependencies:
- supports-color
@@ -26490,11 +25634,10 @@ snapshots:
react: 19.2.4
optionalDependencies:
'@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- expo-router: 55.0.5(c6fvoo4nweiab5jjqzamvkmewm)
+ expo-router: 55.0.5(fleqby3xtsmzebby5l5omzr6ue)
react-dom: 19.2.4(react@19.2.4)
transitivePeerDependencies:
- supports-color
- optional: true
'@expo/schema-utils@0.1.7': {}
@@ -26556,7 +25699,6 @@ snapshots:
expo-font: 55.0.4(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- optional: true
'@expo/ws-tunnel@1.0.6': {}
@@ -26620,22 +25762,6 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@hapi/address@5.1.1':
- dependencies:
- '@hapi/hoek': 11.0.7
-
- '@hapi/formula@3.0.2': {}
-
- '@hapi/hoek@11.0.7': {}
-
- '@hapi/pinpoint@2.0.1': {}
-
- '@hapi/tlds@1.1.6': {}
-
- '@hapi/topo@6.0.2':
- dependencies:
- '@hapi/hoek': 11.0.7
-
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -26874,146 +26000,6 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
- '@inquirer/ansi@1.0.2': {}
-
- '@inquirer/checkbox@4.3.2(@types/node@22.19.1)':
- dependencies:
- '@inquirer/ansi': 1.0.2
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- yoctocolors-cjs: 2.1.3
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/confirm@5.1.21(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/core@10.3.2(@types/node@22.19.1)':
- dependencies:
- '@inquirer/ansi': 1.0.2
- '@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- cli-width: 4.1.0
- mute-stream: 2.0.0
- signal-exit: 4.1.0
- wrap-ansi: 6.2.0
- yoctocolors-cjs: 2.1.3
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/editor@4.2.23(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/external-editor': 1.0.3(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/expand@4.0.23(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- yoctocolors-cjs: 2.1.3
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/external-editor@1.0.3(@types/node@22.19.1)':
- dependencies:
- chardet: 2.1.1
- iconv-lite: 0.7.2
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/figures@1.0.15': {}
-
- '@inquirer/input@4.3.1(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/number@3.0.23(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/password@4.0.23(@types/node@22.19.1)':
- dependencies:
- '@inquirer/ansi': 1.0.2
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/prompts@7.10.1(@types/node@22.19.1)':
- dependencies:
- '@inquirer/checkbox': 4.3.2(@types/node@22.19.1)
- '@inquirer/confirm': 5.1.21(@types/node@22.19.1)
- '@inquirer/editor': 4.2.23(@types/node@22.19.1)
- '@inquirer/expand': 4.0.23(@types/node@22.19.1)
- '@inquirer/input': 4.3.1(@types/node@22.19.1)
- '@inquirer/number': 3.0.23(@types/node@22.19.1)
- '@inquirer/password': 4.0.23(@types/node@22.19.1)
- '@inquirer/rawlist': 4.1.11(@types/node@22.19.1)
- '@inquirer/search': 3.2.2(@types/node@22.19.1)
- '@inquirer/select': 4.4.2(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/prompts@7.3.2(@types/node@22.19.1)':
- dependencies:
- '@inquirer/checkbox': 4.3.2(@types/node@22.19.1)
- '@inquirer/confirm': 5.1.21(@types/node@22.19.1)
- '@inquirer/editor': 4.2.23(@types/node@22.19.1)
- '@inquirer/expand': 4.0.23(@types/node@22.19.1)
- '@inquirer/input': 4.3.1(@types/node@22.19.1)
- '@inquirer/number': 3.0.23(@types/node@22.19.1)
- '@inquirer/password': 4.0.23(@types/node@22.19.1)
- '@inquirer/rawlist': 4.1.11(@types/node@22.19.1)
- '@inquirer/search': 3.2.2(@types/node@22.19.1)
- '@inquirer/select': 4.4.2(@types/node@22.19.1)
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/rawlist@4.1.11(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- yoctocolors-cjs: 2.1.3
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/search@3.2.2(@types/node@22.19.1)':
- dependencies:
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- yoctocolors-cjs: 2.1.3
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/select@4.4.2(@types/node@22.19.1)':
- dependencies:
- '@inquirer/ansi': 1.0.2
- '@inquirer/core': 10.3.2(@types/node@22.19.1)
- '@inquirer/figures': 1.0.15
- '@inquirer/type': 3.0.10(@types/node@22.19.1)
- yoctocolors-cjs: 2.1.3
- optionalDependencies:
- '@types/node': 22.19.1
-
- '@inquirer/type@3.0.10(@types/node@22.19.1)':
- optionalDependencies:
- '@types/node': 22.19.1
-
'@ioredis/commands@1.5.0': {}
'@isaacs/cliui@8.0.2':
@@ -27058,6 +26044,7 @@ snapshots:
jest-message-util: 30.3.0
jest-util: 30.3.0
slash: 3.0.0
+ optional: true
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))':
dependencies:
@@ -27094,7 +26081,7 @@ snapshots:
- supports-color
- ts-node
- '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))':
+ '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))':
dependencies:
'@jest/console': 30.3.0
'@jest/pattern': 30.0.1
@@ -27109,7 +26096,7 @@ snapshots:
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.3.0
- jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
jest-haste-map: 30.3.0
jest-message-util: 30.3.0
jest-regex-util: 30.0.1
@@ -27130,7 +26117,7 @@ snapshots:
- ts-node
optional: true
- '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
+ '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))':
dependencies:
'@jest/console': 30.3.0
'@jest/pattern': 30.0.1
@@ -27145,7 +26132,7 @@ snapshots:
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.3.0
- jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
+ jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))
jest-haste-map: 30.3.0
jest-message-util: 30.3.0
jest-regex-util: 30.0.1
@@ -27164,12 +26151,122 @@ snapshots:
- esbuild-register
- supports-color
- ts-node
+ optional: true
+
+ '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))':
+ dependencies:
+ '@jest/console': 30.3.0
+ '@jest/pattern': 30.0.1
+ '@jest/reporters': 30.3.0
+ '@jest/test-result': 30.3.0
+ '@jest/transform': 30.3.0
+ '@jest/types': 30.3.0
+ '@types/node': 22.19.1
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ exit-x: 0.2.2
+ graceful-fs: 4.2.11
+ jest-changed-files: 30.3.0
+ jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))
+ jest-haste-map: 30.3.0
+ jest-message-util: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-resolve-dependencies: 30.3.0
+ jest-runner: 30.3.0
+ jest-runtime: 30.3.0
+ jest-snapshot: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ jest-watcher: 30.3.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
+
+ '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))':
+ dependencies:
+ '@jest/console': 30.3.0
+ '@jest/pattern': 30.0.1
+ '@jest/reporters': 30.3.0
+ '@jest/test-result': 30.3.0
+ '@jest/transform': 30.3.0
+ '@jest/types': 30.3.0
+ '@types/node': 22.19.1
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ exit-x: 0.2.2
+ graceful-fs: 4.2.11
+ jest-changed-files: 30.3.0
+ jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))
+ jest-haste-map: 30.3.0
+ jest-message-util: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-resolve-dependencies: 30.3.0
+ jest-runner: 30.3.0
+ jest-runtime: 30.3.0
+ jest-snapshot: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ jest-watcher: 30.3.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
+
+ '@jest/core@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))':
+ dependencies:
+ '@jest/console': 30.3.0
+ '@jest/pattern': 30.0.1
+ '@jest/reporters': 30.3.0
+ '@jest/test-result': 30.3.0
+ '@jest/transform': 30.3.0
+ '@jest/types': 30.3.0
+ '@types/node': 22.19.1
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ exit-x: 0.2.2
+ graceful-fs: 4.2.11
+ jest-changed-files: 30.3.0
+ jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ jest-haste-map: 30.3.0
+ jest-message-util: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-resolve-dependencies: 30.3.0
+ jest-runner: 30.3.0
+ jest-runtime: 30.3.0
+ jest-snapshot: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ jest-watcher: 30.3.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
'@jest/create-cache-key-function@29.7.0':
dependencies:
'@jest/types': 29.6.3
- '@jest/diff-sequences@30.3.0': {}
+ '@jest/diff-sequences@30.3.0':
+ optional: true
'@jest/environment@29.7.0':
dependencies:
@@ -27184,6 +26281,7 @@ snapshots:
'@jest/types': 30.3.0
'@types/node': 22.19.1
jest-mock: 30.3.0
+ optional: true
'@jest/expect-utils@29.7.0':
dependencies:
@@ -27192,6 +26290,7 @@ snapshots:
'@jest/expect-utils@30.3.0':
dependencies:
'@jest/get-type': 30.1.0
+ optional: true
'@jest/expect@29.7.0':
dependencies:
@@ -27206,6 +26305,7 @@ snapshots:
jest-snapshot: 30.3.0
transitivePeerDependencies:
- supports-color
+ optional: true
'@jest/fake-timers@29.7.0':
dependencies:
@@ -27224,8 +26324,10 @@ snapshots:
jest-message-util: 30.3.0
jest-mock: 30.3.0
jest-util: 30.3.0
+ optional: true
- '@jest/get-type@30.1.0': {}
+ '@jest/get-type@30.1.0':
+ optional: true
'@jest/globals@29.7.0':
dependencies:
@@ -27244,11 +26346,13 @@ snapshots:
jest-mock: 30.3.0
transitivePeerDependencies:
- supports-color
+ optional: true
'@jest/pattern@30.0.1':
dependencies:
'@types/node': 22.19.1
jest-regex-util: 30.0.1
+ optional: true
'@jest/reporters@29.7.0':
dependencies:
@@ -27306,6 +26410,7 @@ snapshots:
v8-to-istanbul: 9.3.0
transitivePeerDependencies:
- supports-color
+ optional: true
'@jest/schemas@29.6.3':
dependencies:
@@ -27314,6 +26419,7 @@ snapshots:
'@jest/schemas@30.0.5':
dependencies:
'@sinclair/typebox': 0.34.41
+ optional: true
'@jest/snapshot-utils@30.3.0':
dependencies:
@@ -27321,6 +26427,7 @@ snapshots:
chalk: 4.1.2
graceful-fs: 4.2.11
natural-compare: 1.4.0
+ optional: true
'@jest/source-map@29.6.3':
dependencies:
@@ -27333,6 +26440,7 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
callsites: 3.1.0
graceful-fs: 4.2.11
+ optional: true
'@jest/test-result@29.7.0':
dependencies:
@@ -27347,6 +26455,7 @@ snapshots:
'@jest/types': 30.3.0
'@types/istanbul-lib-coverage': 2.0.6
collect-v8-coverage: 1.0.3
+ optional: true
'@jest/test-sequencer@29.7.0':
dependencies:
@@ -27361,6 +26470,7 @@ snapshots:
graceful-fs: 4.2.11
jest-haste-map: 30.3.0
slash: 3.0.0
+ optional: true
'@jest/transform@29.7.0':
dependencies:
@@ -27400,6 +26510,7 @@ snapshots:
write-file-atomic: 5.0.1
transitivePeerDependencies:
- supports-color
+ optional: true
'@jest/types@29.6.3':
dependencies:
@@ -27419,6 +26530,7 @@ snapshots:
'@types/node': 22.19.1
'@types/yargs': 17.0.35
chalk: 4.1.2
+ optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -27518,12 +26630,6 @@ snapshots:
dependencies:
svelte: 5.44.0
- '@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2)':
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- axios: 1.14.0
- rxjs: 7.8.2
-
'@nestjs/cli@10.4.9':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
@@ -27550,32 +26656,6 @@ snapshots:
- uglify-js
- webpack-cli
- '@nestjs/cli@11.0.16(@types/node@22.19.1)(esbuild@0.27.4)':
- dependencies:
- '@angular-devkit/core': 19.2.19(chokidar@4.0.3)
- '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3)
- '@angular-devkit/schematics-cli': 19.2.19(@types/node@22.19.1)(chokidar@4.0.3)
- '@inquirer/prompts': 7.10.1(@types/node@22.19.1)
- '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
- ansis: 4.2.0
- chokidar: 4.0.3
- cli-table3: 0.6.5
- commander: 4.1.1
- fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.4))
- glob: 13.0.0
- node-emoji: 1.11.0
- ora: 5.4.1
- tsconfig-paths: 4.2.0
- tsconfig-paths-webpack-plugin: 4.2.0
- typescript: 5.9.3
- webpack: 5.104.1(esbuild@0.27.4)
- webpack-node-externals: 3.0.0
- transitivePeerDependencies:
- - '@types/node'
- - esbuild
- - uglify-js
- - webpack-cli
-
'@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 20.4.1
@@ -27621,14 +26701,6 @@ snapshots:
lodash: 4.17.21
rxjs: 7.8.2
- '@nestjs/config@4.0.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- dotenv: 17.2.3
- dotenv-expand: 12.0.3
- lodash: 4.17.23
- rxjs: 7.8.2
-
'@nestjs/core@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -27663,20 +26735,6 @@ snapshots:
transitivePeerDependencies:
- encoding
- '@nestjs/core@11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nuxt/opencollective': 0.4.1
- fast-safe-stringify: 2.1.1
- iterare: 1.2.1
- path-to-regexp: 8.3.0
- reflect-metadata: 0.2.2
- rxjs: 7.8.2
- tslib: 2.8.1
- uid: 2.0.2
- optionalDependencies:
- '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
-
'@nestjs/platform-express@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)':
dependencies:
'@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -27702,18 +26760,6 @@ snapshots:
- supports-color
optional: true
- '@nestjs/platform-express@11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)':
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- cors: 2.8.6
- express: 5.2.1
- multer: 2.1.1
- path-to-regexp: 8.3.0
- tslib: 2.8.1
- transitivePeerDependencies:
- - supports-color
-
'@nestjs/platform-socket.io@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.20)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -27762,28 +26808,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
- '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
- dependencies:
- '@angular-devkit/core': 19.2.17(chokidar@4.0.3)
- '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3)
- comment-json: 4.4.1
- jsonc-parser: 3.3.1
- pluralize: 8.0.0
- typescript: 5.9.3
- transitivePeerDependencies:
- - chokidar
-
- '@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- boxen: 5.1.2
- check-disk-space: 3.4.0
- reflect-metadata: 0.2.2
- rxjs: 7.8.2
- optionalDependencies:
- '@nestjs/axios': 4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.14.0)(rxjs@7.8.2)
-
'@nestjs/testing@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-express@10.4.20)':
dependencies:
'@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -27792,14 +26816,6 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
- '@nestjs/testing@11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)':
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- tslib: 2.8.1
- optionalDependencies:
- '@nestjs/platform-express': 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
-
'@nestjs/websockets@10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(@nestjs/platform-socket.io@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -27870,8 +26886,6 @@ snapshots:
'@noble/ciphers@2.0.1': {}
- '@noble/hashes@1.8.0': {}
-
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5':
@@ -27888,10 +26902,6 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
- '@nuxt/opencollective@0.4.1':
- dependencies:
- consola: 3.4.2
-
'@nuxtjs/opencollective@0.3.2(encoding@0.1.13)':
dependencies:
chalk: 4.1.2
@@ -28202,10 +27212,6 @@ snapshots:
'@pagefind/windows-x64@1.4.0':
optional: true
- '@paralleldrive/cuid2@2.3.1':
- dependencies:
- '@noble/hashes': 1.8.0
-
'@petamoriken/float16@3.9.3': {}
'@pixi/colord@2.9.6': {}
@@ -29097,8 +28103,7 @@ snapshots:
'@react-native/assets-registry@0.83.2': {}
- '@react-native/assets-registry@0.84.1':
- optional: true
+ '@react-native/assets-registry@0.84.1': {}
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.28.5)':
dependencies:
@@ -29255,7 +28260,6 @@ snapshots:
nullthrows: 1.1.1
tinyglobby: 0.2.15
yargs: 17.7.2
- optional: true
'@react-native/community-cli-plugin@0.81.4':
dependencies:
@@ -29312,7 +28316,6 @@ snapshots:
- bufferutil
- supports-color
- utf-8-validate
- optional: true
'@react-native/debugger-frontend@0.81.4': {}
@@ -29320,8 +28323,7 @@ snapshots:
'@react-native/debugger-frontend@0.83.2': {}
- '@react-native/debugger-frontend@0.84.1':
- optional: true
+ '@react-native/debugger-frontend@0.84.1': {}
'@react-native/debugger-shell@0.83.2':
dependencies:
@@ -29335,7 +28337,6 @@ snapshots:
fb-dotslash: 0.5.8
transitivePeerDependencies:
- supports-color
- optional: true
'@react-native/dev-middleware@0.81.4':
dependencies:
@@ -29410,7 +28411,6 @@ snapshots:
- bufferutil
- supports-color
- utf-8-validate
- optional: true
'@react-native/gradle-plugin@0.81.4': {}
@@ -29418,8 +28418,7 @@ snapshots:
'@react-native/gradle-plugin@0.83.2': {}
- '@react-native/gradle-plugin@0.84.1':
- optional: true
+ '@react-native/gradle-plugin@0.84.1': {}
'@react-native/js-polyfills@0.81.4': {}
@@ -29427,8 +28426,7 @@ snapshots:
'@react-native/js-polyfills@0.83.2': {}
- '@react-native/js-polyfills@0.84.1':
- optional: true
+ '@react-native/js-polyfills@0.84.1': {}
'@react-native/normalize-colors@0.74.89': {}
@@ -29438,8 +28436,7 @@ snapshots:
'@react-native/normalize-colors@0.83.2': {}
- '@react-native/normalize-colors@0.84.1':
- optional: true
+ '@react-native/normalize-colors@0.84.1': {}
'@react-native/virtualized-lists@0.81.4(@types/react@19.2.14)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -29494,7 +28491,6 @@ snapshots:
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
- optional: true
'@react-navigation/bottom-tabs@7.15.5(@react-navigation/native@7.1.33(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.7.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-screens@4.24.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -30294,7 +29290,8 @@ snapshots:
'@sinclair/typebox@0.27.8': {}
- '@sinclair/typebox@0.34.41': {}
+ '@sinclair/typebox@0.34.41':
+ optional: true
'@sindresorhus/is@7.1.1': {}
@@ -30309,6 +29306,7 @@ snapshots:
'@sinonjs/fake-timers@15.1.1':
dependencies:
'@sinonjs/commons': 3.0.1
+ optional: true
'@smithy/abort-controller@4.2.12':
dependencies:
@@ -30942,8 +29940,6 @@ snapshots:
'@sqlite.org/sqlite-wasm@3.49.1-build4': {}
- '@stablelib/base64@1.0.1': {}
-
'@standard-schema/spec@1.1.0': {}
'@supabase/auth-js@2.84.0':
@@ -31435,7 +30431,7 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
- '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -31445,10 +30441,10 @@ snapshots:
react-test-renderer: 19.1.0(react@19.1.0)
redent: 3.0.0
optionalDependencies:
- jest: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
optional: true
- '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -31458,10 +30454,10 @@ snapshots:
react-test-renderer: 19.1.0(react@19.2.0)
redent: 3.0.0
optionalDependencies:
- jest: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))
optional: true
- '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -31471,10 +30467,36 @@ snapshots:
react-test-renderer: 19.1.0(react@19.2.0)
redent: 3.0.0
optionalDependencies:
- jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))
optional: true
- '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ jest-matcher-utils: 30.3.0
+ picocolors: 1.1.1
+ pretty-format: 30.3.0
+ react: 19.2.0
+ react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
+ react-test-renderer: 19.1.0(react@19.2.0)
+ redent: 3.0.0
+ optionalDependencies:
+ jest: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))
+ optional: true
+
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ jest-matcher-utils: 30.3.0
+ picocolors: 1.1.1
+ pretty-format: 30.3.0
+ react: 19.2.0
+ react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
+ react-test-renderer: 19.1.0(react@19.2.0)
+ redent: 3.0.0
+ optionalDependencies:
+ jest: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ optional: true
+
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -31484,10 +30506,10 @@ snapshots:
react-test-renderer: 19.1.0(react@19.2.0)
redent: 3.0.0
optionalDependencies:
- jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
optional: true
- '@testing-library/react-native@13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)':
+ '@testing-library/react-native@13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -31497,7 +30519,7 @@ snapshots:
react-test-renderer: 19.1.0(react@19.2.4)
redent: 3.0.0
optionalDependencies:
- jest: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
optional: true
'@testing-library/svelte-core@1.0.0(svelte@5.44.0)':
@@ -31564,13 +30586,17 @@ snapshots:
'@trysound/sax@0.2.0': {}
- '@tsconfig/node10@1.0.12': {}
+ '@tsconfig/node10@1.0.12':
+ optional: true
- '@tsconfig/node12@1.0.11': {}
+ '@tsconfig/node12@1.0.11':
+ optional: true
- '@tsconfig/node14@1.0.3': {}
+ '@tsconfig/node14@1.0.3':
+ optional: true
- '@tsconfig/node16@1.0.4': {}
+ '@tsconfig/node16@1.0.4':
+ optional: true
'@tybys/wasm-util@0.10.1':
dependencies:
@@ -31622,8 +30648,6 @@ snapshots:
'@types/cookie@0.6.0': {}
- '@types/cookiejar@2.1.5': {}
-
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.1
@@ -31830,11 +30854,6 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
- '@types/jest@30.0.0':
- dependencies:
- expect: 30.3.0
- pretty-format: 30.3.0
-
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
@@ -31855,8 +30874,6 @@ snapshots:
'@types/mdx@2.0.13': {}
- '@types/methods@1.1.4': {}
-
'@types/mime-types@2.1.4': {}
'@types/mime@1.3.5': {}
@@ -31979,18 +30996,6 @@ snapshots:
'@types/suncalc@1.9.2': {}
- '@types/superagent@8.1.9':
- dependencies:
- '@types/cookiejar': 2.1.5
- '@types/methods': 1.1.4
- '@types/node': 22.19.1
- form-data: 4.0.5
-
- '@types/supertest@6.0.3':
- dependencies:
- '@types/methods': 1.1.4
- '@types/superagent': 8.1.9
-
'@types/tedious@4.0.14':
dependencies:
'@types/node': 22.19.1
@@ -32006,8 +31011,6 @@ snapshots:
'@types/trusted-types@2.0.7': {}
- '@types/ua-parser-js@0.7.39': {}
-
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -32049,16 +31052,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/scope-manager': 6.21.0
- '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
- '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/type-utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@@ -32107,15 +31110,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/parser': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/scope-manager': 7.18.0
- '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
- '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
+ '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 7.18.0
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
@@ -32207,14 +31210,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 6.21.0
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
optionalDependencies:
typescript: 5.3.3
transitivePeerDependencies:
@@ -32246,14 +31249,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/parser@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
'@typescript-eslint/visitor-keys': 7.18.0
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
optionalDependencies:
typescript: 5.3.3
transitivePeerDependencies:
@@ -32379,12 +31382,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/type-utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
- '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/utils': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
ts-api-utils: 1.4.3(typescript@5.3.3)
optionalDependencies:
typescript: 5.3.3
@@ -32415,12 +31418,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/type-utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
- '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/utils': 7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
debug: 4.4.3
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
ts-api-utils: 1.4.3(typescript@5.3.3)
optionalDependencies:
typescript: 5.3.3
@@ -32602,15 +31605,15 @@ snapshots:
- supports-color
- typescript
- '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/utils@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@types/json-schema': 7.0.15
'@types/semver': 7.7.1
'@typescript-eslint/scope-manager': 6.21.0
'@typescript-eslint/types': 6.21.0
'@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3)
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
semver: 7.7.3
transitivePeerDependencies:
- supports-color
@@ -32641,13 +31644,13 @@ snapshots:
- supports-color
- typescript
- '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)':
+ '@typescript-eslint/utils@7.18.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@typescript-eslint/scope-manager': 7.18.0
'@typescript-eslint/types': 7.18.0
'@typescript-eslint/typescript-estree': 7.18.0(typescript@5.3.3)
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
transitivePeerDependencies:
- supports-color
- typescript
@@ -33250,7 +32253,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
optional: true
'@vitest/ui@4.0.14(vitest@4.0.14)':
@@ -33495,19 +32498,10 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
- accepts@2.0.0:
- dependencies:
- mime-types: 3.0.2
- negotiator: 1.0.0
-
acorn-import-attributes@1.9.5(acorn@8.15.0):
dependencies:
acorn: 8.15.0
- acorn-import-phases@1.0.4(acorn@8.15.0):
- dependencies:
- acorn: 8.15.0
-
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -33536,10 +32530,6 @@ snapshots:
optionalDependencies:
ajv: 8.17.1
- ajv-formats@3.0.1(ajv@8.17.1):
- optionalDependencies:
- ajv: 8.17.1
-
ajv-keywords@3.5.2(ajv@6.12.6):
dependencies:
ajv: 6.12.6
@@ -33606,7 +32596,8 @@ snapshots:
ansi-styles@6.2.3: {}
- ansis@4.2.0: {}
+ ansis@4.2.0:
+ optional: true
any-promise@1.3.0: {}
@@ -33619,7 +32610,8 @@ snapshots:
arg@4.1.0: {}
- arg@4.1.3: {}
+ arg@4.1.3:
+ optional: true
arg@5.0.2: {}
@@ -33773,6 +32765,108 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1):
+ dependencies:
+ '@astrojs/compiler': 2.13.0
+ '@astrojs/internal-helpers': 0.7.5
+ '@astrojs/markdown-remark': 6.3.9
+ '@astrojs/telemetry': 3.3.0
+ '@capsizecss/unpack': 3.0.1
+ '@oslojs/encoding': 1.1.0
+ '@rollup/pluginutils': 5.3.0(rollup@4.53.3)
+ acorn: 8.15.0
+ aria-query: 5.3.2
+ axobject-query: 4.1.0
+ boxen: 8.0.1
+ ci-info: 4.3.1
+ clsx: 2.1.1
+ common-ancestor-path: 1.0.1
+ cookie: 1.1.0
+ cssesc: 3.0.0
+ debug: 4.4.3
+ deterministic-object-hash: 2.0.2
+ devalue: 5.5.0
+ diff: 5.2.0
+ dlv: 1.1.3
+ dset: 3.1.4
+ es-module-lexer: 1.7.0
+ esbuild: 0.25.12
+ estree-walker: 3.0.3
+ flattie: 1.1.1
+ fontace: 0.3.1
+ github-slugger: 2.0.0
+ html-escaper: 3.0.3
+ http-cache-semantics: 4.2.0
+ import-meta-resolve: 4.2.0
+ js-yaml: 4.1.1
+ magic-string: 0.30.21
+ magicast: 0.5.1
+ mrmime: 2.0.1
+ neotraverse: 0.6.18
+ p-limit: 6.2.0
+ p-queue: 8.1.1
+ package-manager-detector: 1.5.0
+ piccolore: 0.1.3
+ picomatch: 4.0.3
+ prompts: 2.4.2
+ rehype: 13.0.2
+ semver: 7.7.3
+ shiki: 3.15.0
+ smol-toml: 1.5.2
+ svgo: 4.0.0
+ tinyexec: 1.0.2
+ tinyglobby: 0.2.15
+ tsconfck: 3.1.6(typescript@5.9.3)
+ ultrahtml: 1.6.0
+ unifont: 0.6.0
+ unist-util-visit: 5.0.0
+ unstorage: 1.17.3(@netlify/blobs@10.4.1)(ioredis@5.9.2)
+ vfile: 6.0.3
+ vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
+ vitefu: 1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
+ xxhash-wasm: 1.1.0
+ yargs-parser: 21.1.1
+ yocto-spinner: 0.2.3
+ zod: 3.25.76
+ zod-to-json-schema: 3.25.0(zod@3.25.76)
+ zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
+ optionalDependencies:
+ sharp: 0.34.5
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@types/node'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - db0
+ - idb-keyval
+ - ioredis
+ - jiti
+ - less
+ - lightningcss
+ - rollup
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - typescript
+ - uploadthing
+ - yaml
+
astro@5.16.0(@netlify/blobs@10.4.1)(@types/node@20.19.25)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1):
dependencies:
'@astrojs/compiler': 2.13.0
@@ -34213,14 +33307,6 @@ snapshots:
await-lock@2.2.2:
optional: true
- axios@1.14.0:
- dependencies:
- follow-redirects: 1.15.11
- form-data: 4.0.5
- proxy-from-env: 2.1.0
- transitivePeerDependencies:
- - debug
-
axobject-query@4.1.0: {}
babel-jest@29.7.0(@babel/core@7.28.5):
@@ -34248,6 +33334,7 @@ snapshots:
slash: 3.0.0
transitivePeerDependencies:
- supports-color
+ optional: true
babel-plugin-istanbul@6.1.1:
dependencies:
@@ -34268,6 +33355,7 @@ snapshots:
test-exclude: 6.0.0
transitivePeerDependencies:
- supports-color
+ optional: true
babel-plugin-jest-hoist@29.6.3:
dependencies:
@@ -34279,6 +33367,7 @@ snapshots:
babel-plugin-jest-hoist@30.3.0:
dependencies:
'@types/babel__core': 7.20.5
+ optional: true
babel-plugin-module-resolver@5.0.2:
dependencies:
@@ -34466,6 +33555,7 @@ snapshots:
'@babel/core': 7.28.5
babel-plugin-jest-hoist: 30.3.0
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5)
+ optional: true
babel-runtime@6.26.0:
dependencies:
@@ -34489,8 +33579,6 @@ snapshots:
base64id@2.0.0:
optional: true
- baseline-browser-mapping@2.10.12: {}
-
baseline-browser-mapping@2.8.31: {}
bcp-47-match@2.0.3: {}
@@ -34578,35 +33666,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- body-parser@2.2.2:
- dependencies:
- bytes: 3.1.2
- content-type: 1.0.5
- debug: 4.4.3
- http-errors: 2.0.0
- iconv-lite: 0.7.2
- on-finished: 2.4.1
- qs: 6.15.0
- raw-body: 3.0.2
- type-is: 2.0.1
- transitivePeerDependencies:
- - supports-color
-
boolbase@1.0.0: {}
bowser@2.13.1: {}
- boxen@5.1.2:
- dependencies:
- ansi-align: 3.0.1
- camelcase: 6.3.0
- chalk: 4.1.2
- cli-boxes: 2.2.1
- string-width: 4.2.3
- type-fest: 0.20.2
- widest-line: 3.1.0
- wrap-ansi: 7.0.0
-
boxen@8.0.1:
dependencies:
ansi-align: 3.0.1
@@ -34661,18 +33724,6 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.1.4(browserslist@4.28.0)
- browserslist@4.28.1:
- dependencies:
- baseline-browser-mapping: 2.10.12
- caniuse-lite: 1.0.30001781
- electron-to-chromium: 1.5.328
- node-releases: 2.0.27
- update-browserslist-db: 1.2.3(browserslist@4.28.1)
-
- bs-logger@0.2.6:
- dependencies:
- fast-json-stable-stringify: 2.1.0
-
bs58@6.0.0:
dependencies:
base-x: 5.0.1
@@ -34776,8 +33827,6 @@ snapshots:
caniuse-lite@1.0.30001757: {}
- caniuse-lite@1.0.30001781: {}
-
ccount@2.0.1: {}
chai@4.5.0:
@@ -34827,10 +33876,6 @@ snapshots:
chardet@0.7.0: {}
- chardet@2.1.1: {}
-
- check-disk-space@3.4.0: {}
-
check-error@1.0.3:
dependencies:
get-func-name: 2.0.2
@@ -34912,7 +33957,8 @@ snapshots:
cjs-module-lexer@1.4.3: {}
- cjs-module-lexer@2.1.1: {}
+ cjs-module-lexer@2.1.1:
+ optional: true
class-transformer@0.5.1: {}
@@ -34926,8 +33972,6 @@ snapshots:
dependencies:
clsx: 2.1.1
- cli-boxes@2.2.1: {}
-
cli-boxes@3.0.0: {}
cli-color@2.0.4:
@@ -35057,8 +34101,6 @@ snapshots:
commondir@1.0.1: {}
- component-emitter@1.3.1: {}
-
compressible@2.0.18:
dependencies:
mime-db: 1.54.0
@@ -35114,8 +34156,6 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
- content-disposition@1.0.1: {}
-
content-type@1.0.5: {}
convert-source-map@2.0.0: {}
@@ -35124,20 +34164,17 @@ snapshots:
cookie-signature@1.0.6: {}
- cookie-signature@1.2.2: {}
-
cookie@0.6.0: {}
cookie@0.7.1: {}
- cookie@0.7.2: {}
+ cookie@0.7.2:
+ optional: true
cookie@1.1.0: {}
cookie@1.1.1: {}
- cookiejar@2.1.4: {}
-
core-js-compat@3.47.0:
dependencies:
browserslist: 4.28.0
@@ -35155,6 +34192,7 @@ snapshots:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
+ optional: true
cosmiconfig@8.3.6(typescript@5.7.2):
dependencies:
@@ -35165,15 +34203,6 @@ snapshots:
optionalDependencies:
typescript: 5.7.2
- cosmiconfig@8.3.6(typescript@5.9.3):
- dependencies:
- import-fresh: 3.3.1
- js-yaml: 4.1.1
- parse-json: 5.2.0
- path-type: 4.0.0
- optionalDependencies:
- typescript: 5.9.3
-
create-jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
@@ -35189,7 +34218,8 @@ snapshots:
- supports-color
- ts-node
- create-require@1.1.1: {}
+ create-require@1.1.1:
+ optional: true
cron-parser@4.9.0:
dependencies:
@@ -35561,8 +34591,6 @@ snapshots:
destroy@1.2.0: {}
- detect-europe-js@0.1.2: {}
-
detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
@@ -35588,18 +34616,14 @@ snapshots:
dexie@4.4.1: {}
- dezalgo@1.0.4:
- dependencies:
- asap: 2.0.6
- wrappy: 1.0.2
-
dfa@1.2.0: {}
didyoumean@1.2.2: {}
diff-sequences@29.6.3: {}
- diff@4.0.2: {}
+ diff@4.0.2:
+ optional: true
diff@5.2.0: {}
@@ -35669,18 +34693,12 @@ snapshots:
dependencies:
dotenv: 16.6.1
- dotenv-expand@12.0.3:
- dependencies:
- dotenv: 16.6.1
-
dotenv@16.4.5: {}
dotenv@16.4.7: {}
dotenv@16.6.1: {}
- dotenv@17.2.3: {}
-
drizzle-kit@0.28.1:
dependencies:
'@drizzle-team/brocli': 0.10.2
@@ -35771,8 +34789,6 @@ snapshots:
electron-to-chromium@1.5.260: {}
- electron-to-chromium@1.5.328: {}
-
emittery@0.13.1: {}
emmet@2.4.11:
@@ -36309,6 +35325,11 @@ snapshots:
eslint: 9.39.1(jiti@2.6.1)
semver: 7.7.3
+ eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@1.21.7)):
+ dependencies:
+ eslint: 9.39.1(jiti@1.21.7)
+ semver: 7.7.3
+
eslint-compat-utils@0.6.5(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -36373,14 +35394,14 @@ snapshots:
dependencies:
eslint: 8.57.1
- eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)):
- dependencies:
- eslint: 9.39.1(jiti@1.21.7)
-
eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
+ eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@1.21.7)):
+ dependencies:
+ eslint: 9.39.1(jiti@1.21.7)
+
eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -36405,17 +35426,17 @@ snapshots:
- supports-color
- typescript
- eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)(typescript@5.3.3):
+ eslint-config-universe@12.1.0(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)(typescript@5.3.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
- '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
- eslint: 9.39.1(jiti@1.21.7)
- eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2)
- eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7))
- eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@1.21.7))
+ '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
+ '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
+ eslint: 9.39.1(jiti@2.6.1)
+ eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-node: 11.1.0(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-prettier: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)
+ eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-react-hooks: 4.6.2(eslint@9.39.1(jiti@2.6.1))
optionalDependencies:
prettier: 3.6.2
transitivePeerDependencies:
@@ -36500,12 +35521,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
- eslint: 9.39.1(jiti@1.21.7)
+ '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
+ eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
@@ -36542,6 +35563,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@1.21.7)):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7))
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@typescript-eslint/types': 8.48.0
+ astro-eslint-parser: 1.2.2
+ eslint: 9.39.1(jiti@1.21.7)
+ eslint-compat-utils: 0.6.5(eslint@9.39.1(jiti@1.21.7))
+ globals: 16.5.0
+ postcss: 8.5.6
+ postcss-selector-parser: 7.1.0
+ transitivePeerDependencies:
+ - supports-color
+
eslint-plugin-astro@1.5.0(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
@@ -36569,12 +35604,6 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
- eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@1.21.7)):
- dependencies:
- eslint: 9.39.1(jiti@1.21.7)
- eslint-utils: 2.1.0
- regexpp: 3.2.0
-
eslint-plugin-es@3.0.1(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -36628,7 +35657,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint@9.39.1(jiti@1.21.7)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -36637,9 +35666,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
- eslint: 9.39.1(jiti@1.21.7)
+ eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@1.21.7))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -36651,7 +35680,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.3.3)
+ '@typescript-eslint/parser': 6.21.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.3.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -36769,16 +35798,6 @@ snapshots:
resolve: 1.22.11
semver: 6.3.1
- eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@1.21.7)):
- dependencies:
- eslint: 9.39.1(jiti@1.21.7)
- eslint-plugin-es: 3.0.1(eslint@9.39.1(jiti@1.21.7))
- eslint-utils: 2.1.0
- ignore: 5.3.2
- minimatch: 3.1.2
- resolve: 1.22.11
- semver: 6.3.1
-
eslint-plugin-node@11.1.0(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -36809,16 +35828,6 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 8.10.2(eslint@8.57.1)
- eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7))(prettier@3.6.2):
- dependencies:
- eslint: 9.39.1(jiti@1.21.7)
- prettier: 3.6.2
- prettier-linter-helpers: 1.0.0
- synckit: 0.11.11
- optionalDependencies:
- '@types/eslint': 9.6.1
- eslint-config-prettier: 8.10.2(eslint@9.39.1(jiti@1.21.7))
-
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@8.10.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -36843,10 +35852,6 @@ snapshots:
dependencies:
eslint: 8.57.1
- eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@1.21.7)):
- dependencies:
- eslint: 9.39.1(jiti@1.21.7)
-
eslint-plugin-react-hooks@4.6.2(eslint@9.39.1(jiti@2.6.1)):
dependencies:
eslint: 9.39.1(jiti@2.6.1)
@@ -36877,28 +35882,6 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
- eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)):
- dependencies:
- array-includes: 3.1.9
- array.prototype.findlast: 1.2.5
- array.prototype.flatmap: 1.3.3
- array.prototype.tosorted: 1.1.4
- doctrine: 2.1.0
- es-iterator-helpers: 1.2.1
- eslint: 9.39.1(jiti@1.21.7)
- estraverse: 5.3.0
- hasown: 2.0.2
- jsx-ast-utils: 3.3.5
- minimatch: 3.1.2
- object.entries: 1.1.9
- object.fromentries: 2.0.8
- object.values: 1.2.1
- prop-types: 15.8.1
- resolve: 2.0.0-next.5
- semver: 6.3.1
- string.prototype.matchall: 4.0.12
- string.prototype.repeat: 1.0.0
-
eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)):
dependencies:
array-includes: 3.1.9
@@ -37245,7 +36228,8 @@ snapshots:
exit-hook@2.2.1: {}
- exit-x@0.2.2: {}
+ exit-x@0.2.2:
+ optional: true
exit@0.1.2: {}
@@ -37269,6 +36253,7 @@ snapshots:
jest-message-util: 30.3.0
jest-mock: 30.3.0
jest-util: 30.3.0
+ optional: true
expo-application@55.0.9(expo@55.0.5):
dependencies:
@@ -37338,7 +36323,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- typescript
- optional: true
expo-audio@55.0.8(expo-asset@55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
@@ -37448,7 +36432,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- typescript
- optional: true
expo-dev-client@6.0.18(expo@55.0.5):
dependencies:
@@ -37508,7 +36491,6 @@ snapshots:
dependencies:
expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- optional: true
expo-font@14.0.10(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -37551,7 +36533,6 @@ snapshots:
fontfaceobserver: 2.3.0
react: 19.2.4
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- optional: true
expo-glass-effect@55.0.8(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -37585,11 +36566,11 @@ snapshots:
expo-image-loader@55.0.0(expo@55.0.5):
dependencies:
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-image-picker@55.0.12(expo@55.0.5):
dependencies:
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-image-loader: 55.0.0(expo@55.0.5)
expo-image@55.0.6(expo@54.0.25)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
@@ -37646,7 +36627,6 @@ snapshots:
dependencies:
expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react: 19.2.4
- optional: true
expo-linear-gradient@15.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0):
dependencies:
@@ -37812,7 +36792,6 @@ snapshots:
invariant: 2.2.4
react: 19.2.4
react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- optional: true
expo-notifications@55.0.12(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
@@ -37828,7 +36807,7 @@ snapshots:
- supports-color
- typescript
- expo-router@55.0.5(2y7463a4qw2trccupvperml2iy):
+ expo-router@55.0.5(3s5jslrd73ksoqlrblc4nkbaxq):
dependencies:
'@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
'@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -37847,7 +36826,7 @@ snapshots:
expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server: 55.0.6
- expo-symbols: 55.0.5(expo-font@14.0.9)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.11
@@ -37865,11 +36844,11 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.0)
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
optionalDependencies:
- '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0)
react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
@@ -37878,58 +36857,7 @@ snapshots:
- expo-font
- supports-color
- expo-router@55.0.5(c6fvoo4nweiab5jjqzamvkmewm):
- dependencies:
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- '@expo/schema-utils': 55.0.2
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
- '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- '@react-navigation/native': 7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- client-only: 0.0.1
- debug: 4.4.3
- escape-string-regexp: 4.0.0
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
- expo-constants: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3)
- expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- expo-linking: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
- expo-server: 55.0.6
- expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- fast-deep-equal: 3.1.3
- invariant: 2.2.4
- nanoid: 3.3.11
- query-string: 7.1.3
- react: 19.2.4
- react-fast-compare: 3.2.2
- react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
- react-native-is-edge-to-edge: 1.2.1(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- react-native-safe-area-context: 5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- react-native-screens: 4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- semver: 7.6.3
- server-only: 0.0.1
- sf-symbols-typescript: 2.2.0
- shallowequal: 1.1.0
- use-latest-callback: 0.2.6(react@19.2.4)
- vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- optionalDependencies:
- '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-gesture-handler@2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)
- react-dom: 19.2.4(react@19.2.4)
- react-native-gesture-handler: 2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- transitivePeerDependencies:
- - '@react-native-masked-view/masked-view'
- - '@types/react'
- - '@types/react-dom'
- - expo-font
- - supports-color
- optional: true
-
- expo-router@55.0.5(fwp4zczusao7cokd7ir5ej7q2m):
+ expo-router@55.0.5(aimrwth6cdz52vf6mf3gs6ehmm):
dependencies:
'@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
'@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -37967,7 +36895,7 @@ snapshots:
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0)
react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -37979,7 +36907,7 @@ snapshots:
- expo-font
- supports-color
- expo-router@55.0.5(ghhebxqzkten3dvea4x4aksnhu):
+ expo-router@55.0.5(bxgrffxues5ttf7xlcab6p2yce):
dependencies:
'@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
'@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -38017,7 +36945,7 @@ snapshots:
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0)
react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -38029,57 +36957,7 @@ snapshots:
- expo-font
- supports-color
- expo-router@55.0.5(pt32crn2332c4slkypkbrcsbnu):
- dependencies:
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@expo/schema-utils': 55.0.2
- '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0)
- '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- client-only: 0.0.1
- debug: 4.4.3
- escape-string-regexp: 4.0.0
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
- expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.3.3)
- expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
- expo-server: 55.0.6
- expo-symbols: 55.0.5(expo-font@14.0.10)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- fast-deep-equal: 3.1.3
- invariant: 2.2.4
- nanoid: 3.3.11
- query-string: 7.1.3
- react: 19.2.0
- react-fast-compare: 3.2.2
- react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
- react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- semver: 7.6.3
- server-only: 0.0.1
- sf-symbols-typescript: 2.2.0
- shallowequal: 1.1.0
- use-latest-callback: 0.2.6(react@19.2.0)
- vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- optionalDependencies:
- '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
- react-dom: 19.2.0(react@19.2.0)
- react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- transitivePeerDependencies:
- - '@react-native-masked-view/masked-view'
- - '@types/react'
- - '@types/react-dom'
- - expo-font
- - supports-color
-
- expo-router@55.0.5(qwxmdxiornnsbyvrtivw4g2joq):
+ expo-router@55.0.5(dfjkn535zxizfdfnhu5vc4kgbu):
dependencies:
'@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@54.0.25)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.2.4(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
@@ -38117,7 +36995,7 @@ snapshots:
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.1.0))(react@19.1.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.30.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.7.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-screens@4.24.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
react-dom: 19.2.4(react@19.1.0)
react-native-gesture-handler: 2.30.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
@@ -38130,57 +37008,58 @@ snapshots:
- supports-color
optional: true
- expo-router@55.0.5(xtsqo6xlpeezoeb4r7ibrbxkam):
+ expo-router@55.0.5(fleqby3xtsmzebby5l5omzr6ue):
dependencies:
- '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
'@expo/schema-utils': 55.0.2
- '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
- '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ '@react-navigation/native': 7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
- expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3)
- expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.12.2(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
+ expo-constants: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3)
+ expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ expo-linking: 55.0.7(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
expo-server: 55.0.6
- expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.11
query-string: 7.1.3
- react: 19.2.0
+ react: 19.2.4
react-fast-compare: 3.2.2
- react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)
- react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- react-native-screens: 4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ react-native: 0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ react-native-safe-area-context: 5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ react-native-screens: 4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
semver: 7.6.3
server-only: 0.0.1
sf-symbols-typescript: 2.2.0
shallowequal: 1.1.0
- use-latest-callback: 0.2.6(react@19.2.0)
- vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ use-latest-callback: 0.2.6(react@19.2.4)
+ vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
optionalDependencies:
- '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
- react-dom: 19.2.0(react@19.2.0)
- react-native-gesture-handler: 2.30.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- react-native-reanimated: 4.2.1(react-native-worklets@0.7.2(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
- react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-gesture-handler@2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-reanimated@4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.7.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.24.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react-test-renderer@19.1.0(react@19.2.4))(react@19.2.4)
+ react-dom: 19.2.4(react@19.2.4)
+ react-native-gesture-handler: 2.30.0(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ react-native-reanimated: 4.2.2(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
- '@types/react-dom'
- expo-font
- supports-color
+ optional: true
- expo-router@55.0.5(zjxxslxkejh45wtx7sxpliuuwu):
+ expo-router@55.0.5(oinrqag3kg73e5vim3pjq4pqwa):
dependencies:
'@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
'@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -38218,7 +37097,7 @@ snapshots:
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
optionalDependencies:
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
- '@testing-library/react-native': 13.3.3(jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0)
react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
@@ -38230,9 +37109,159 @@ snapshots:
- expo-font
- supports-color
+ expo-router@55.0.5(q4hy7bav5ztjlrgmjhpxrzxgqu):
+ dependencies:
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@expo/schema-utils': 55.0.2
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.0)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ client-only: 0.0.1
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3)
+ expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-server: 55.0.6
+ expo-symbols: 55.0.5(expo-font@55.0.4)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ fast-deep-equal: 3.1.3
+ invariant: 2.2.4
+ nanoid: 3.3.11
+ query-string: 7.1.3
+ react: 19.2.0
+ react-fast-compare: 3.2.2
+ react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ react-native-screens: 4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ semver: 7.6.3
+ server-only: 0.0.1
+ sf-symbols-typescript: 2.2.0
+ shallowequal: 1.1.0
+ use-latest-callback: 0.2.6(react@19.2.0)
+ vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ optionalDependencies:
+ '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.30.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ react-dom: 19.2.0(react@19.2.0)
+ react-native-gesture-handler: 2.30.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ react-native-reanimated: 4.2.1(react-native-worklets@0.7.2(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+ - '@types/react'
+ - '@types/react-dom'
+ - expo-font
+ - supports-color
+
+ expo-router@55.0.5(yyfvq4xdvwwomf27esym4nbkxe):
+ dependencies:
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@expo/schema-utils': 55.0.2
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ client-only: 0.0.1
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
+ expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.3.3)
+ expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
+ expo-server: 55.0.6
+ expo-symbols: 55.0.5(expo-font@14.0.10)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ fast-deep-equal: 3.1.3
+ invariant: 2.2.4
+ nanoid: 3.3.11
+ query-string: 7.1.3
+ react: 19.2.0
+ react-fast-compare: 3.2.2
+ react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ semver: 7.6.3
+ server-only: 0.0.1
+ sf-symbols-typescript: 2.2.0
+ shallowequal: 1.1.0
+ use-latest-callback: 0.2.6(react@19.2.0)
+ vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ optionalDependencies:
+ '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ react-dom: 19.2.0(react@19.2.0)
+ react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.7.4(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+ - '@types/react'
+ - '@types/react-dom'
+ - expo-font
+ - supports-color
+
+ expo-router@55.0.5(zafn55q75v7o47cyjbdbemtb7m):
+ dependencies:
+ '@expo/log-box': 55.0.7(@expo/dom-webview@55.0.3)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@expo/metro-runtime': 6.1.2(expo@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@expo/schema-utils': 55.0.2
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.1.17)(react@19.2.0)
+ '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@react-navigation/bottom-tabs': 7.15.5(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@react-navigation/native': 7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ client-only: 0.0.1
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-constants: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(typescript@5.9.3)
+ expo-glass-effect: 55.0.8(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ expo-image: 55.0.6(expo@55.0.5)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ expo-linking: 55.0.7(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-server: 55.0.6
+ expo-symbols: 55.0.5(expo-font@14.0.9)(expo@55.0.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ fast-deep-equal: 3.1.3
+ invariant: 2.2.4
+ nanoid: 3.3.11
+ query-string: 7.1.3
+ react: 19.2.0
+ react-fast-compare: 3.2.2
+ react-native: 0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-safe-area-context: 5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-screens: 4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ semver: 7.6.3
+ server-only: 0.0.1
+ sf-symbols-typescript: 2.2.0
+ shallowequal: 1.1.0
+ use-latest-callback: 0.2.6(react@19.2.0)
+ vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ optionalDependencies:
+ '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-gesture-handler@2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native-screens@4.16.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ '@testing-library/react-native': 13.3.3(jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react-test-renderer@19.1.0(react@19.2.0))(react@19.2.0)
+ react-dom: 19.2.0(react@19.2.0)
+ react-native-gesture-handler: 2.28.0(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)
+ react-native-web: 0.21.2(encoding@0.1.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+ - '@types/react'
+ - '@types/react-dom'
+ - expo-font
+ - supports-color
+
expo-secure-store@55.0.8(expo@55.0.5):
dependencies:
- expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo: 55.0.5(@babel/core@7.28.5)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@6.1.2)(expo-router@55.0.5)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.12.2(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.2.0))(react@19.2.0)(typescript@5.3.3)
expo-server@1.0.4: {}
@@ -38637,7 +37666,6 @@ snapshots:
- supports-color
- typescript
- utf-8-validate
- optional: true
exponential-backoff@3.1.3: {}
@@ -38677,39 +37705,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- express@5.2.1:
- dependencies:
- accepts: 2.0.0
- body-parser: 2.2.2
- content-disposition: 1.0.1
- content-type: 1.0.5
- cookie: 0.7.2
- cookie-signature: 1.2.2
- debug: 4.4.3
- depd: 2.0.0
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- finalhandler: 2.1.1
- fresh: 2.0.0
- http-errors: 2.0.0
- merge-descriptors: 2.0.0
- mime-types: 3.0.2
- on-finished: 2.4.1
- once: 1.4.0
- parseurl: 1.3.3
- proxy-addr: 2.0.7
- qs: 6.14.0
- range-parser: 1.2.1
- router: 2.2.0
- send: 1.2.1
- serve-static: 2.2.1
- statuses: 2.0.1
- type-is: 2.0.1
- vary: 1.1.2
- transitivePeerDependencies:
- - supports-color
-
expressive-code@0.40.2:
dependencies:
'@expressive-code/core': 0.40.2
@@ -38763,8 +37758,6 @@ snapshots:
fast-safe-stringify@2.1.1: {}
- fast-sha256@1.3.0: {}
-
fast-uri@3.1.0: {}
fast-xml-parser@4.5.3:
@@ -38877,17 +37870,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- finalhandler@2.1.1:
- dependencies:
- debug: 4.4.3
- encodeurl: 2.0.0
- escape-html: 1.0.3
- on-finished: 2.4.1
- parseurl: 1.3.3
- statuses: 2.0.1
- transitivePeerDependencies:
- - supports-color
-
find-babel-config@2.1.2:
dependencies:
json5: 2.2.3
@@ -38933,8 +37915,6 @@ snapshots:
flow-enums-runtime@0.0.6: {}
- follow-redirects@1.15.11: {}
-
fontace@0.3.1:
dependencies:
'@types/fontkit': 2.0.8
@@ -38988,23 +37968,6 @@ snapshots:
typescript: 5.7.2
webpack: 5.97.1
- fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.4)):
- dependencies:
- '@babel/code-frame': 7.27.1
- chalk: 4.1.2
- chokidar: 4.0.3
- cosmiconfig: 8.3.6(typescript@5.9.3)
- deepmerge: 4.3.1
- fs-extra: 10.1.0
- memfs: 3.5.3
- minimatch: 3.1.2
- node-abort-controller: 3.1.1
- schema-utils: 3.3.0
- semver: 7.7.3
- tapable: 2.3.0
- typescript: 5.9.3
- webpack: 5.104.1(esbuild@0.27.4)
-
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -39013,12 +37976,6 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
- formidable@3.5.4:
- dependencies:
- '@paralleldrive/cuid2': 2.3.1
- dezalgo: 1.0.4
- once: 1.4.0
-
forwarded-parse@2.1.2: {}
forwarded@0.2.0: {}
@@ -39029,8 +37986,6 @@ snapshots:
fresh@0.5.2: {}
- fresh@2.0.0: {}
-
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -39270,15 +38225,6 @@ snapshots:
ufo: 1.6.1
uncrypto: 0.1.3
- handlebars@4.7.9:
- dependencies:
- minimist: 1.2.8
- neo-async: 2.6.2
- source-map: 0.6.1
- wordwrap: 1.0.0
- optionalDependencies:
- uglify-js: 3.19.3
-
has-bigints@1.1.0: {}
has-flag@3.0.0: {}
@@ -39500,8 +38446,7 @@ snapshots:
hermes-compiler@0.14.1: {}
- hermes-compiler@250829098.0.9:
- optional: true
+ hermes-compiler@250829098.0.9: {}
hermes-estree@0.29.1: {}
@@ -39570,14 +38515,6 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
- http-errors@2.0.1:
- dependencies:
- depd: 2.0.0
- inherits: 2.0.4
- setprototypeof: 1.2.0
- statuses: 2.0.2
- toidentifier: 1.0.1
-
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -39640,10 +38577,6 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
- iconv-lite@0.7.2:
- dependencies:
- safer-buffer: 2.1.2
-
idb@7.1.1: {}
ieee754@1.2.1: {}
@@ -39903,8 +38836,6 @@ snapshots:
is-promise@2.2.2: {}
- is-promise@4.0.0: {}
-
is-reference@1.2.1:
dependencies:
'@types/estree': 1.0.8
@@ -39928,8 +38859,6 @@ snapshots:
dependencies:
call-bound: 1.0.4
- is-standalone-pwa@0.1.1: {}
-
is-stream@2.0.1: {}
is-stream@3.0.0: {}
@@ -40084,6 +39013,7 @@ snapshots:
execa: 5.1.1
jest-util: 30.3.0
p-limit: 3.1.0
+ optional: true
jest-circus@29.7.0:
dependencies:
@@ -40136,6 +39066,7 @@ snapshots:
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
+ optional: true
jest-cli@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
@@ -40156,15 +39087,15 @@ snapshots:
- supports-color
- ts-node
- jest-cli@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest-cli@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
'@jest/test-result': 30.3.0
'@jest/types': 30.3.0
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-config: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
jest-util: 30.3.0
jest-validate: 30.3.0
yargs: 17.7.2
@@ -40176,34 +39107,15 @@ snapshots:
- ts-node
optional: true
- jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
+ jest-cli@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)):
dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))
'@jest/test-result': 30.3.0
'@jest/types': 30.3.0
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
- jest-util: 30.3.0
- jest-validate: 30.3.0
- yargs: 17.7.2
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - esbuild-register
- - supports-color
- - ts-node
-
- jest-cli@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4)):
- dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
- '@jest/test-result': 30.3.0
- '@jest/types': 30.3.0
- chalk: 4.1.2
- exit-x: 0.2.2
- import-local: 3.2.0
- jest-config: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-config: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))
jest-util: 30.3.0
jest-validate: 30.3.0
yargs: 17.7.2
@@ -40215,15 +39127,55 @@ snapshots:
- ts-node
optional: true
- jest-cli@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest-cli@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)):
dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))
'@jest/test-result': 30.3.0
'@jest/types': 30.3.0
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-config: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
+
+ jest-cli@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)):
+ dependencies:
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))
+ '@jest/test-result': 30.3.0
+ '@jest/types': 30.3.0
+ chalk: 4.1.2
+ exit-x: 0.2.2
+ import-local: 3.2.0
+ jest-config: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
+
+ jest-cli@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
+ dependencies:
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ '@jest/test-result': 30.3.0
+ '@jest/types': 30.3.0
+ chalk: 4.1.2
+ exit-x: 0.2.2
+ import-local: 3.2.0
+ jest-config: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
jest-util: 30.3.0
jest-validate: 30.3.0
yargs: 17.7.2
@@ -40297,7 +39249,7 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-config@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest-config@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/get-type': 30.1.0
@@ -40325,12 +39277,13 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.25
esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
optional: true
- jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/get-type': 30.1.0
@@ -40358,12 +39311,13 @@ snapshots:
optionalDependencies:
'@types/node': 22.19.1
esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
optional: true
- jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
+ jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/get-type': 30.1.0
@@ -40391,12 +39345,115 @@ snapshots:
optionalDependencies:
'@types/node': 22.19.1
esbuild-register: 3.6.0(esbuild@0.27.4)
- ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
+ ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.3.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
+ optional: true
- jest-config@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@jest/get-type': 30.1.0
+ '@jest/pattern': 30.0.1
+ '@jest/test-sequencer': 30.3.0
+ '@jest/types': 30.3.0
+ babel-jest: 30.3.0(@babel/core@7.28.5)
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ deepmerge: 4.3.1
+ glob: 10.5.0
+ graceful-fs: 4.2.11
+ jest-circus: 30.3.0
+ jest-docblock: 30.2.0
+ jest-environment-node: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-runner: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ parse-json: 5.2.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 22.19.1
+ esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.3.3)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ optional: true
+
+ jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@jest/get-type': 30.1.0
+ '@jest/pattern': 30.0.1
+ '@jest/test-sequencer': 30.3.0
+ '@jest/types': 30.3.0
+ babel-jest: 30.3.0(@babel/core@7.28.5)
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ deepmerge: 4.3.1
+ glob: 10.5.0
+ graceful-fs: 4.2.11
+ jest-circus: 30.3.0
+ jest-docblock: 30.2.0
+ jest-environment-node: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-runner: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ parse-json: 5.2.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 22.19.1
+ esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.8.3)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ optional: true
+
+ jest-config@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@jest/get-type': 30.1.0
+ '@jest/pattern': 30.0.1
+ '@jest/test-sequencer': 30.3.0
+ '@jest/types': 30.3.0
+ babel-jest: 30.3.0(@babel/core@7.28.5)
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ deepmerge: 4.3.1
+ glob: 10.5.0
+ graceful-fs: 4.2.11
+ jest-circus: 30.3.0
+ jest-docblock: 30.2.0
+ jest-environment-node: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-runner: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ parse-json: 5.2.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 22.19.1
+ esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ optional: true
+
+ jest-config@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/get-type': 30.1.0
@@ -40424,12 +39481,13 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.1
esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.3.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
optional: true
- jest-config@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest-config@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)):
dependencies:
'@babel/core': 7.28.5
'@jest/get-type': 30.1.0
@@ -40455,7 +39513,43 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
+ '@types/node': 24.10.1
esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.8.3)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ optional: true
+
+ jest-config@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@jest/get-type': 30.1.0
+ '@jest/pattern': 30.0.1
+ '@jest/test-sequencer': 30.3.0
+ '@jest/types': 30.3.0
+ babel-jest: 30.3.0(@babel/core@7.28.5)
+ chalk: 4.1.2
+ ci-info: 4.3.1
+ deepmerge: 4.3.1
+ glob: 10.5.0
+ graceful-fs: 4.2.11
+ jest-circus: 30.3.0
+ jest-docblock: 30.2.0
+ jest-environment-node: 30.3.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.3.0
+ jest-runner: 30.3.0
+ jest-util: 30.3.0
+ jest-validate: 30.3.0
+ parse-json: 5.2.0
+ pretty-format: 30.3.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 24.10.1
+ esbuild-register: 3.6.0(esbuild@0.27.4)
+ ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -40474,6 +39568,7 @@ snapshots:
'@jest/get-type': 30.1.0
chalk: 4.1.2
pretty-format: 30.3.0
+ optional: true
jest-docblock@29.7.0:
dependencies:
@@ -40482,6 +39577,7 @@ snapshots:
jest-docblock@30.2.0:
dependencies:
detect-newline: 3.1.0
+ optional: true
jest-each@29.7.0:
dependencies:
@@ -40498,6 +39594,7 @@ snapshots:
chalk: 4.1.2
jest-util: 30.3.0
pretty-format: 30.3.0
+ optional: true
jest-environment-node@29.7.0:
dependencies:
@@ -40517,6 +39614,7 @@ snapshots:
jest-mock: 30.3.0
jest-util: 30.3.0
jest-validate: 30.3.0
+ optional: true
jest-get-type@29.6.3: {}
@@ -40550,6 +39648,7 @@ snapshots:
walker: 1.0.8
optionalDependencies:
fsevents: 2.3.3
+ optional: true
jest-leak-detector@29.7.0:
dependencies:
@@ -40560,6 +39659,7 @@ snapshots:
dependencies:
'@jest/get-type': 30.1.0
pretty-format: 30.3.0
+ optional: true
jest-matcher-utils@29.7.0:
dependencies:
@@ -40574,6 +39674,7 @@ snapshots:
chalk: 4.1.2
jest-diff: 30.3.0
pretty-format: 30.3.0
+ optional: true
jest-message-util@29.7.0:
dependencies:
@@ -40598,6 +39699,7 @@ snapshots:
pretty-format: 30.3.0
slash: 3.0.0
stack-utils: 2.0.6
+ optional: true
jest-mock@29.7.0:
dependencies:
@@ -40610,6 +39712,7 @@ snapshots:
'@jest/types': 30.3.0
'@types/node': 22.19.1
jest-util: 30.3.0
+ optional: true
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
optionalDependencies:
@@ -40618,10 +39721,12 @@ snapshots:
jest-pnp-resolver@1.2.3(jest-resolve@30.3.0):
optionalDependencies:
jest-resolve: 30.3.0
+ optional: true
jest-regex-util@29.6.3: {}
- jest-regex-util@30.0.1: {}
+ jest-regex-util@30.0.1:
+ optional: true
jest-resolve-dependencies@29.7.0:
dependencies:
@@ -40636,6 +39741,7 @@ snapshots:
jest-snapshot: 30.3.0
transitivePeerDependencies:
- supports-color
+ optional: true
jest-resolve@29.7.0:
dependencies:
@@ -40659,6 +39765,7 @@ snapshots:
jest-validate: 30.3.0
slash: 3.0.0
unrs-resolver: 1.11.1
+ optional: true
jest-runner@29.7.0:
dependencies:
@@ -40712,6 +39819,7 @@ snapshots:
source-map-support: 0.5.13
transitivePeerDependencies:
- supports-color
+ optional: true
jest-runtime@29.7.0:
dependencies:
@@ -40766,6 +39874,7 @@ snapshots:
strip-bom: 4.0.0
transitivePeerDependencies:
- supports-color
+ optional: true
jest-snapshot@29.7.0:
dependencies:
@@ -40817,6 +39926,7 @@ snapshots:
synckit: 0.11.11
transitivePeerDependencies:
- supports-color
+ optional: true
jest-util@29.7.0:
dependencies:
@@ -40835,6 +39945,7 @@ snapshots:
ci-info: 4.3.1
graceful-fs: 4.2.11
picomatch: 4.0.3
+ optional: true
jest-validate@29.7.0:
dependencies:
@@ -40853,6 +39964,7 @@ snapshots:
chalk: 4.1.2
leven: 3.1.0
pretty-format: 30.3.0
+ optional: true
jest-watcher@29.7.0:
dependencies:
@@ -40875,6 +39987,7 @@ snapshots:
emittery: 0.13.1
jest-util: 30.3.0
string-length: 4.0.2
+ optional: true
jest-worker@27.5.1:
dependencies:
@@ -40896,6 +40009,7 @@ snapshots:
jest-util: 30.3.0
merge-stream: 2.0.0
supports-color: 8.1.1
+ optional: true
jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
dependencies:
@@ -40909,12 +40023,12 @@ snapshots:
- supports-color
- ts-node
- jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest@30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)):
dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
'@jest/types': 30.3.0
import-local: 3.2.0
- jest-cli: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-cli: 30.3.0(@types/node@20.19.25)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -40923,25 +40037,12 @@ snapshots:
- ts-node
optional: true
- jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
+ jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3)):
dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))
'@jest/types': 30.3.0
import-local: 3.2.0
- jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - esbuild-register
- - supports-color
- - ts-node
-
- jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4)):
- dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
- '@jest/types': 30.3.0
- import-local: 3.2.0
- jest-cli: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-cli: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -40950,12 +40051,40 @@ snapshots:
- ts-node
optional: true
- jest@30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)):
+ jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3)):
dependencies:
- '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))
'@jest/types': 30.3.0
import-local: 3.2.0
- jest-cli: 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))
+ jest-cli: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3))
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
+
+ jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3)):
+ dependencies:
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))
+ '@jest/types': 30.3.0
+ import-local: 3.2.0
+ jest-cli: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3))
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+ optional: true
+
+ jest@30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
+ dependencies:
+ '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
+ '@jest/types': 30.3.0
+ import-local: 3.2.0
+ jest-cli: 30.3.0(@types/node@24.10.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -40970,16 +40099,6 @@ snapshots:
jiti@2.6.1: {}
- joi@18.1.1:
- dependencies:
- '@hapi/address': 5.1.1
- '@hapi/formula': 3.0.2
- '@hapi/hoek': 11.0.7
- '@hapi/pinpoint': 2.0.1
- '@hapi/tlds': 1.1.6
- '@hapi/topo': 6.0.2
- '@standard-schema/spec': 1.1.0
-
jose@6.1.2: {}
joycon@3.1.1: {}
@@ -41387,8 +40506,6 @@ snapshots:
lodash.isarguments@3.1.0: {}
- lodash.memoize@4.1.2: {}
-
lodash.merge@4.6.2: {}
lodash.sortby@4.7.0: {}
@@ -41456,10 +40573,6 @@ snapshots:
dependencies:
sourcemap-codec: 1.4.8
- magic-string@0.30.17:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
-
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -41484,7 +40597,8 @@ snapshots:
dependencies:
semver: 7.7.3
- make-error@1.3.6: {}
+ make-error@1.3.6:
+ optional: true
makeerror@1.0.12:
dependencies:
@@ -41739,8 +40853,6 @@ snapshots:
media-typer@0.3.0: {}
- media-typer@1.1.0: {}
-
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
@@ -41762,8 +40874,6 @@ snapshots:
merge-descriptors@1.0.3: {}
- merge-descriptors@2.0.0: {}
-
merge-options@3.0.4:
dependencies:
is-plain-obj: 2.1.0
@@ -42411,14 +41521,8 @@ snapshots:
dependencies:
mime-db: 1.52.0
- mime-types@3.0.2:
- dependencies:
- mime-db: 1.54.0
-
mime@1.6.0: {}
- mime@2.6.0: {}
-
mime@3.0.0: {}
mimic-fn@1.2.0: {}
@@ -42556,21 +41660,12 @@ snapshots:
type-is: 1.6.18
xtend: 4.0.2
- multer@2.1.1:
- dependencies:
- append-field: 1.0.0
- busboy: 1.6.0
- concat-stream: 2.0.0
- type-is: 1.6.18
-
multitars@0.2.4: {}
mute-stream@0.0.8: {}
mute-stream@1.0.0: {}
- mute-stream@2.0.0: {}
-
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -42581,8 +41676,6 @@ snapshots:
nanoid@3.3.11: {}
- nanoid@5.1.7: {}
-
nanostores@1.1.0: {}
napi-postinstall@0.3.4: {}
@@ -42649,21 +41742,12 @@ snapshots:
negotiator@0.6.4: {}
- negotiator@1.0.0: {}
-
neo-async@2.6.2: {}
neotraverse@0.6.18: {}
nested-error-stacks@2.0.1: {}
- nestjs-cls@6.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2):
- dependencies:
- '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- '@nestjs/core': 11.1.17(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
- reflect-metadata: 0.2.2
- rxjs: 7.8.2
-
next-tick@1.1.0: {}
nlcst-to-string@4.0.0:
@@ -43055,8 +42139,6 @@ snapshots:
path-to-regexp@6.3.0: {}
- path-to-regexp@8.3.0: {}
-
path-type@4.0.0: {}
pathe@1.1.2: {}
@@ -43105,8 +42187,6 @@ snapshots:
picomatch@4.0.1: {}
- picomatch@4.0.2: {}
-
picomatch@4.0.3: {}
pidtree@0.6.0: {}
@@ -43168,12 +42248,8 @@ snapshots:
pngjs@5.0.0: {}
- pocketbase@0.26.8: {}
-
possible-typed-array-names@1.1.0: {}
- postal-mime@2.7.3: {}
-
postcss-import@15.1.0(postcss@8.5.6):
dependencies:
postcss: 8.5.6
@@ -43356,6 +42432,7 @@ snapshots:
'@jest/schemas': 30.0.5
ansi-styles: 5.2.0
react-is: 18.3.1
+ optional: true
prism-svelte@0.4.7: {}
@@ -43398,8 +42475,6 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
- proxy-from-env@2.1.0: {}
-
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
@@ -43409,7 +42484,8 @@ snapshots:
pure-rand@6.1.0: {}
- pure-rand@7.0.1: {}
+ pure-rand@7.0.1:
+ optional: true
qrcode-terminal@0.11.0: {}
@@ -43427,10 +42503,6 @@ snapshots:
dependencies:
side-channel: 1.1.0
- qs@6.15.0:
- dependencies:
- side-channel: 1.1.0
-
quansync@0.2.11: {}
query-string@7.1.3:
@@ -43461,13 +42533,6 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
- raw-body@3.0.2:
- dependencies:
- bytes: 3.1.2
- http-errors: 2.0.1
- iconv-lite: 0.7.2
- unpipe: 1.0.0
-
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
@@ -44449,7 +43514,6 @@ snapshots:
- bufferutil
- supports-color
- utf-8-validate
- optional: true
react-refresh@0.14.2: {}
@@ -44878,11 +43942,6 @@ snapshots:
reselect@4.1.8: {}
- resend@6.9.4:
- dependencies:
- postal-mime: 2.7.3
- svix: 1.86.0
-
resolve-cwd@3.0.0:
dependencies:
resolve-from: 5.0.0
@@ -45001,16 +44060,6 @@ snapshots:
rou3@0.5.1: {}
- router@2.2.0:
- dependencies:
- debug: 4.4.3
- depd: 2.0.0
- is-promise: 4.0.0
- parseurl: 1.3.3
- path-to-regexp: 8.3.0
- transitivePeerDependencies:
- - supports-color
-
rrule@2.8.1:
dependencies:
tslib: 2.8.1
@@ -45155,22 +44204,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- send@1.2.1:
- dependencies:
- debug: 4.4.3
- encodeurl: 2.0.0
- escape-html: 1.0.3
- etag: 1.8.1
- fresh: 2.0.0
- http-errors: 2.0.1
- mime-types: 3.0.2
- ms: 2.1.3
- on-finished: 2.4.1
- range-parser: 1.2.1
- statuses: 2.0.2
- transitivePeerDependencies:
- - supports-color
-
serialize-error@2.1.0: {}
serialize-javascript@6.0.2:
@@ -45186,15 +44219,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- serve-static@2.2.1:
- dependencies:
- encodeurl: 2.0.0
- escape-html: 1.0.3
- parseurl: 1.3.3
- send: 1.2.1
- transitivePeerDependencies:
- - supports-color
-
server-only@0.0.1: {}
set-blocking@2.0.0: {}
@@ -45491,17 +44515,10 @@ snapshots:
standard-as-callback@2.1.0: {}
- standardwebhooks@1.0.0:
- dependencies:
- '@stablelib/base64': 1.0.1
- fast-sha256: 1.3.0
-
statuses@1.5.0: {}
statuses@2.0.1: {}
- statuses@2.0.2: {}
-
std-env@3.10.0: {}
std-env@4.0.0: {}
@@ -45666,12 +44683,6 @@ snapshots:
'@types/node': 22.19.1
qs: 6.14.0
- stripe@18.5.0(@types/node@24.10.1):
- dependencies:
- qs: 6.14.0
- optionalDependencies:
- '@types/node': 24.10.1
-
strnum@1.1.2: {}
strnum@2.1.1: {}
@@ -45718,28 +44729,6 @@ snapshots:
suncalc@1.9.0: {}
- superagent@10.3.0:
- dependencies:
- component-emitter: 1.3.1
- cookiejar: 2.1.4
- debug: 4.4.3
- fast-safe-stringify: 2.1.1
- form-data: 4.0.5
- formidable: 3.5.4
- methods: 1.1.2
- mime: 2.6.0
- qs: 6.15.0
- transitivePeerDependencies:
- - supports-color
-
- supertest@7.2.2:
- dependencies:
- cookie-signature: 1.2.2
- methods: 1.1.2
- superagent: 10.3.0
- transitivePeerDependencies:
- - supports-color
-
supports-color@10.2.2: {}
supports-color@5.5.0:
@@ -45852,11 +44841,6 @@ snapshots:
picocolors: 1.1.1
sax: 1.4.3
- svix@1.86.0:
- dependencies:
- standardwebhooks: 1.0.0
- uuid: 10.0.0
-
symbol-observable@4.0.0: {}
symbol-tree@3.2.4: {}
@@ -45932,16 +44916,6 @@ snapshots:
terser: 5.44.1
webpack: 5.97.1
- terser-webpack-plugin@5.4.0(esbuild@0.27.4)(webpack@5.104.1(esbuild@0.27.4)):
- dependencies:
- '@jridgewell/trace-mapping': 0.3.31
- jest-worker: 27.5.1
- schema-utils: 4.3.3
- terser: 5.44.1
- webpack: 5.104.1(esbuild@0.27.4)
- optionalDependencies:
- esbuild: 0.27.4
-
terser@5.44.1:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -46122,37 +45096,6 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3):
- dependencies:
- bs-logger: 0.2.6
- fast-json-stable-stringify: 2.1.0
- handlebars: 4.7.9
- jest: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.4))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
- json5: 2.2.3
- lodash.memoize: 4.1.2
- make-error: 1.3.6
- semver: 7.7.3
- type-fest: 4.41.0
- typescript: 5.9.3
- yargs-parser: 21.1.1
- optionalDependencies:
- '@babel/core': 7.28.5
- '@jest/transform': 30.3.0
- '@jest/types': 30.3.0
- babel-jest: 30.3.0(@babel/core@7.28.5)
- esbuild: 0.27.4
- jest-util: 30.3.0
-
- ts-loader@9.5.4(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.4)):
- dependencies:
- chalk: 4.1.2
- enhanced-resolve: 5.18.3
- micromatch: 4.0.8
- semver: 7.7.3
- source-map: 0.7.6
- typescript: 5.9.3
- webpack: 5.104.1(esbuild@0.27.4)
-
ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
@@ -46172,6 +45115,25 @@ snapshots:
yn: 3.1.1
optional: true
+ ts-node@10.9.2(@types/node@22.19.1)(typescript@5.3.3):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 22.19.1
+ acorn: 8.15.0
+ acorn-walk: 8.3.4
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.3.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
+ optional: true
+
ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
@@ -46189,6 +45151,45 @@ snapshots:
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
+ optional: true
+
+ ts-node@10.9.2(@types/node@24.10.1)(typescript@5.3.3):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 24.10.1
+ acorn: 8.15.0
+ acorn-walk: 8.3.4
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.3.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
+ optional: true
+
+ ts-node@10.9.2(@types/node@24.10.1)(typescript@5.8.3):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ '@tsconfig/node10': 1.0.12
+ '@tsconfig/node12': 1.0.11
+ '@tsconfig/node14': 1.0.3
+ '@tsconfig/node16': 1.0.4
+ '@types/node': 24.10.1
+ acorn: 8.15.0
+ acorn-walk: 8.3.4
+ arg: 4.1.3
+ create-require: 1.1.1
+ diff: 4.0.2
+ make-error: 1.3.6
+ typescript: 5.8.3
+ v8-compile-cache-lib: 3.0.1
+ yn: 3.1.1
+ optional: true
ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3):
dependencies:
@@ -46333,12 +45334,6 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
- type-is@2.0.1:
- dependencies:
- content-type: 1.0.5
- media-typer: 1.1.0
- mime-types: 3.0.2
-
type@2.7.3: {}
typed-array-buffer@1.0.3:
@@ -46412,27 +45407,16 @@ snapshots:
typescript@5.9.3: {}
- ua-is-frozen@0.1.2: {}
-
ua-parser-js@0.7.41: {}
ua-parser-js@1.0.41: {}
- ua-parser-js@2.0.9:
- dependencies:
- detect-europe-js: 0.1.2
- is-standalone-pwa: 0.1.1
- ua-is-frozen: 0.1.2
-
uc.micro@1.0.6: {}
ufo@1.6.1: {}
ufo@1.6.3: {}
- uglify-js@3.19.3:
- optional: true
-
uid@2.0.2:
dependencies:
'@lukeed/csprng': 1.1.0
@@ -46644,12 +45628,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- update-browserslist-db@1.2.3(browserslist@4.28.1):
- dependencies:
- browserslist: 4.28.1
- escalade: 3.2.0
- picocolors: 1.1.1
-
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -46761,8 +45739,6 @@ snapshots:
utils-merge@1.0.1: {}
- uuid@10.0.0: {}
-
uuid@11.1.0: {}
uuid@13.0.0:
@@ -46770,7 +45746,8 @@ snapshots:
uuid@7.0.3: {}
- v8-compile-cache-lib@3.0.1: {}
+ v8-compile-cache-lib@3.0.1:
+ optional: true
v8-to-istanbul@9.3.0:
dependencies:
@@ -47036,6 +46013,23 @@ snapshots:
lightningcss: 1.30.2
terser: 5.44.1
+ vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1):
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.53.3
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 20.19.25
+ fsevents: 2.3.3
+ jiti: 1.21.7
+ lightningcss: 1.30.2
+ terser: 5.44.1
+ tsx: 4.21.0
+ yaml: 2.8.1
+
vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.12
@@ -47138,6 +46132,10 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.1
+ vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)):
+ optionalDependencies:
+ vite: 6.4.1(@types/node@20.19.25)(jiti@1.21.7)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
+
vitefu@1.1.1(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)):
optionalDependencies:
vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
@@ -47855,38 +46853,6 @@ snapshots:
webpack-sources@3.3.3: {}
- webpack@5.104.1(esbuild@0.27.4):
- dependencies:
- '@types/eslint-scope': 3.7.7
- '@types/estree': 1.0.8
- '@types/json-schema': 7.0.15
- '@webassemblyjs/ast': 1.14.1
- '@webassemblyjs/wasm-edit': 1.14.1
- '@webassemblyjs/wasm-parser': 1.14.1
- acorn: 8.15.0
- acorn-import-phases: 1.0.4(acorn@8.15.0)
- browserslist: 4.28.1
- chrome-trace-event: 1.0.4
- enhanced-resolve: 5.18.3
- es-module-lexer: 2.0.0
- eslint-scope: 5.1.1
- events: 3.3.0
- glob-to-regexp: 0.4.1
- graceful-fs: 4.2.11
- json-parse-even-better-errors: 2.3.1
- loader-runner: 4.3.1
- mime-types: 2.1.35
- neo-async: 2.6.2
- schema-utils: 4.3.3
- tapable: 2.3.0
- terser-webpack-plugin: 5.4.0(esbuild@0.27.4)(webpack@5.104.1(esbuild@0.27.4))
- watchpack: 2.4.4
- webpack-sources: 3.3.3
- transitivePeerDependencies:
- - '@swc/core'
- - esbuild
- - uglify-js
-
webpack@5.97.1:
dependencies:
'@types/eslint-scope': 3.7.7
@@ -48017,10 +46983,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
- widest-line@3.1.0:
- dependencies:
- string-width: 4.2.3
-
widest-line@5.0.0:
dependencies:
string-width: 7.2.0
@@ -48029,8 +46991,6 @@ snapshots:
word-wrap@1.2.5: {}
- wordwrap@1.0.0: {}
-
workbox-background-sync@7.4.0:
dependencies:
idb: 7.1.1
@@ -48203,6 +47163,7 @@ snapshots:
dependencies:
imurmurhash: 0.1.4
signal-exit: 4.1.0
+ optional: true
ws@6.2.3:
dependencies:
@@ -48311,7 +47272,8 @@ snapshots:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
- yn@3.1.1: {}
+ yn@3.1.1:
+ optional: true
yocto-queue@0.1.0: {}
@@ -48321,8 +47283,6 @@ snapshots:
dependencies:
yoctocolors: 2.1.2
- yoctocolors-cjs@2.1.3: {}
-
yoctocolors@2.1.2: {}
youch-core@0.3.3: