mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
Feat: New project chat, uload refactor (postgress), hosting plans, uload landingpage
This commit is contained in:
parent
559eb08d8c
commit
fcf3a344b1
123 changed files with 7106 additions and 3715 deletions
946
docs/BACKEND_ARCHITECTURE.md
Normal file
946
docs/BACKEND_ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,946 @@
|
|||
# Backend-Architektur im Manacore Monorepo
|
||||
|
||||
Diese Dokumentation beschreibt die Backend-Implementierungen aller Projekte im Manacore Monorepo.
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Monorepo enthält 6 Hauptprojekte mit unterschiedlichen Backend-Architekturen:
|
||||
|
||||
| Projekt | Backend-Typ | Datenbank | Status |
|
||||
|---------|-------------|-----------|--------|
|
||||
| **Maerchenzauber** | NestJS v10 | Supabase (PostgreSQL) | Aktiv |
|
||||
| **Manadeck** | NestJS v11 | PostgreSQL + Drizzle ORM | Aktiv |
|
||||
| **Uload** | NestJS v11 | PostgreSQL + Drizzle ORM | Aktiv |
|
||||
| **Picture** | Kein Backend | - | Frontend-only |
|
||||
| **Memoro** | Kein Backend | - | Frontend-only |
|
||||
| **Manacore** | Kein Backend (extern) | - | Externes Backend |
|
||||
|
||||
---
|
||||
|
||||
## 1. Maerchenzauber
|
||||
|
||||
**Pfad:** `/maerchenzauber/apps/backend`
|
||||
|
||||
**Zweck:** KI-gestützte Kindergeschichten-Generierung mit benutzerdefinierten Charakteren.
|
||||
|
||||
### Technologie-Stack
|
||||
|
||||
- **Framework:** NestJS 10.0.0
|
||||
- **Datenbank:** Supabase (PostgreSQL)
|
||||
- **ORM:** `@supabase/supabase-js` v2.81.1
|
||||
- **AI-Services:** Azure OpenAI, Google Gemini, Replicate
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
apps/backend/
|
||||
├── src/
|
||||
│ ├── character/ # Charakter-Modul
|
||||
│ │ ├── character.controller.ts
|
||||
│ │ ├── character.service.ts
|
||||
│ │ └── character.repository.ts
|
||||
│ ├── story/ # Story-Modul
|
||||
│ │ ├── story.controller.ts
|
||||
│ │ ├── story.service.ts
|
||||
│ │ └── pipelines/ # Story-Generierung-Pipelines
|
||||
│ ├── core/ # Kern-Services
|
||||
│ │ └── services/
|
||||
│ │ └── prompting.service.ts
|
||||
│ ├── settings/ # Benutzereinstellungen
|
||||
│ ├── health/ # Health-Checks
|
||||
│ └── feedback/ # Feedback-Modul
|
||||
```
|
||||
|
||||
### Datenbank-Schema
|
||||
|
||||
**Tabellen:**
|
||||
- `characters` - Benutzercharaktere
|
||||
- `stories` - Generierte Geschichten
|
||||
- `story_collections` - Sammlungen von Geschichten
|
||||
- `user_settings` - Benutzereinstellungen
|
||||
|
||||
**Sicherheit:** Row-Level Security (RLS) für Datenzugriffskontrolle
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Mana Core Integration via `@mana-core/nestjs-integration`:
|
||||
|
||||
```typescript
|
||||
// Beispiel: Geschützter Endpoint
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('characters')
|
||||
async getCharacters(@CurrentUser() user: User) {
|
||||
return this.characterService.findByUser(user.id);
|
||||
}
|
||||
```
|
||||
|
||||
**Auth-Endpoint:** `https://mana-core-middleware-111768794939.europe-west3.run.app`
|
||||
|
||||
### AI-Services
|
||||
|
||||
| Service | Verwendung | API |
|
||||
|---------|------------|-----|
|
||||
| Azure OpenAI (GPT-4) | Story-Generierung | `MAERCHENZAUBER_AZURE_OPENAI_ENDPOINT` |
|
||||
| Google Gemini | Charakter-Generierung | `GOOGLE_GEMINI_API_KEY` |
|
||||
| Replicate (Flux) | Bildgenerierung | `REPLICATE_API_TOKEN` |
|
||||
|
||||
### File Storage
|
||||
|
||||
- **Provider:** Supabase Storage
|
||||
- **Bucket:** `maerchenzauber`
|
||||
- **Verwendung:** Charakter- und Story-Bilder
|
||||
|
||||
### Deployment
|
||||
|
||||
- **Plattform:** Google Cloud Run
|
||||
- **Region:** europe-west3
|
||||
- **URL:** `https://storyteller-backend-111768794939.europe-west3.run.app`
|
||||
- **Port:** 3002 (Development)
|
||||
|
||||
---
|
||||
|
||||
## 2. Manadeck
|
||||
|
||||
**Pfad:** `/manadeck/apps/backend`
|
||||
|
||||
**Zweck:** KI-gestützte Lernkarten-Generierung (Flashcards, Quizzes, Mixed).
|
||||
|
||||
### Technologie-Stack
|
||||
|
||||
- **Framework:** NestJS 11.0.1
|
||||
- **Datenbank:** PostgreSQL 16
|
||||
- **ORM:** Drizzle ORM
|
||||
- **AI-Service:** Google Gemini API
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
apps/backend/
|
||||
├── src/
|
||||
│ ├── api.controller.ts # Haupt-API-Endpoints
|
||||
│ ├── public.controller.ts # Öffentliche Endpoints
|
||||
│ ├── health.controller.ts # Health-Checks
|
||||
│ ├── ai.service.ts # AI-Generierung
|
||||
│ └── repositories/
|
||||
│ ├── deck.repository.ts
|
||||
│ ├── card.repository.ts
|
||||
│ ├── user-stats.repository.ts
|
||||
│ └── deck-template.repository.ts
|
||||
```
|
||||
|
||||
### Datenbank-Package
|
||||
|
||||
Das Datenbank-Schema ist in einem separaten Package ausgelagert:
|
||||
|
||||
**Pfad:** `/packages/manadeck-database`
|
||||
|
||||
```typescript
|
||||
// Verwendung im Backend
|
||||
import { db, schema } from '@manacore/manadeck-database';
|
||||
|
||||
const decks = await db.query.decks.findMany({
|
||||
where: eq(schema.decks.userId, userId)
|
||||
});
|
||||
```
|
||||
|
||||
**Drizzle-Konfiguration:**
|
||||
```typescript
|
||||
// drizzle.config.ts
|
||||
export default {
|
||||
schema: './src/schema/*',
|
||||
out: './migrations',
|
||||
driver: 'pg',
|
||||
dbCredentials: {
|
||||
connectionString: process.env.DATABASE_URL
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
```typescript
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
|
||||
@Controller('api')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ApiController {
|
||||
@Post('decks')
|
||||
async createDeck(@CurrentUser() user: User, @Body() dto: CreateDeckDto) {
|
||||
// Credit-Prüfung und Deck-Erstellung
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Credit-System
|
||||
|
||||
Integration mit Mana Core Credit Service:
|
||||
|
||||
```typescript
|
||||
import { CreditClientService } from '@mana-core/nestjs-integration';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(private creditClient: CreditClientService) {}
|
||||
|
||||
async generateDeck(userId: string, input: GenerateInput) {
|
||||
// 1. Credit-Balance prüfen
|
||||
const hasCredits = await this.creditClient.checkBalance(userId, 'DECK_CREATION');
|
||||
|
||||
// 2. Deck generieren
|
||||
const deck = await this.generateWithGemini(input);
|
||||
|
||||
// 3. Credits abziehen
|
||||
await this.creditClient.deduct(userId, 'DECK_CREATION');
|
||||
|
||||
return deck;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AI-Generierung
|
||||
|
||||
**Unterstützte Kartentypen:**
|
||||
- `text` - Textbasierte Karten
|
||||
- `flashcard` - Klassische Lernkarten
|
||||
- `quiz` - Multiple-Choice Quiz
|
||||
- `mixed` - Gemischte Inhalte
|
||||
|
||||
**Schwierigkeitsgrade:**
|
||||
- `beginner`
|
||||
- `intermediate`
|
||||
- `advanced`
|
||||
|
||||
### Docker-Setup
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (Lokale Entwicklung)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: manadeck
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4
|
||||
ports:
|
||||
- "5050:80"
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
- **Docker Image:** Multi-stage Build (Node 18-alpine)
|
||||
- **Port:** 8080
|
||||
- **Health-Check:** `/health`
|
||||
|
||||
---
|
||||
|
||||
## 3. Uload
|
||||
|
||||
**Pfad:** `/uload/apps/backend`
|
||||
|
||||
**Zweck:** URL-Shortener mit Link-Analytics.
|
||||
|
||||
### Technologie-Stack
|
||||
|
||||
- **Framework:** NestJS 11.0.1
|
||||
- **Datenbank:** PostgreSQL 16
|
||||
- **ORM:** Drizzle ORM
|
||||
- **Cache:** Redis (optional)
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
uload/apps/backend/
|
||||
├── src/
|
||||
│ ├── main.ts
|
||||
│ ├── app.module.ts
|
||||
│ ├── config/
|
||||
│ │ └── validation.schema.ts
|
||||
│ ├── controllers/
|
||||
│ │ ├── redirect.controller.ts # GET /:code (public redirect)
|
||||
│ │ ├── links.controller.ts # CRUD /api/links
|
||||
│ │ ├── analytics.controller.ts # GET /api/analytics
|
||||
│ │ └── health.controller.ts
|
||||
│ ├── services/
|
||||
│ │ ├── links.service.ts
|
||||
│ │ ├── redirect.service.ts
|
||||
│ │ └── analytics.service.ts
|
||||
│ └── database/
|
||||
│ ├── database.module.ts
|
||||
│ └── repositories/
|
||||
│ ├── link.repository.ts
|
||||
│ └── click.repository.ts
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### Datenbank-Package
|
||||
|
||||
**Pfad:** `/packages/uload-database`
|
||||
|
||||
```typescript
|
||||
// Verwendung im Backend
|
||||
import { db, links, clicks, eq, desc } from '@manacore/uload-database';
|
||||
|
||||
const userLinks = await db.query.links.findMany({
|
||||
where: eq(links.userId, userId),
|
||||
orderBy: desc(links.createdAt)
|
||||
});
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Auth | Beschreibung |
|
||||
|----------|--------|------|--------------|
|
||||
| `/:code` | GET | Public | Redirect zu Original-URL |
|
||||
| `/api/links` | GET | Protected | Liste aller Links |
|
||||
| `/api/links` | POST | Protected | Link erstellen |
|
||||
| `/api/links/:id` | GET | Protected | Link Details |
|
||||
| `/api/links/:id` | PATCH | Protected | Link aktualisieren |
|
||||
| `/api/links/:id` | DELETE | Protected | Link löschen |
|
||||
| `/api/analytics/:linkId` | GET | Protected | Link-Statistiken |
|
||||
| `/health` | GET | Public | Health Check |
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Mana Core Integration via `@mana-core/nestjs-integration`:
|
||||
|
||||
```typescript
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
|
||||
@Controller('api/links')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LinksController {
|
||||
@Get()
|
||||
async getLinks(@CurrentUser() user: any) {
|
||||
return this.linksService.getLinks(user.sub);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
- **Docker Image:** Multi-stage Build (Node 20-alpine)
|
||||
- **Port:** 3003
|
||||
- **Health-Check:** `/health`
|
||||
|
||||
---
|
||||
|
||||
## 4. Picture
|
||||
|
||||
**Pfad:** `/picture`
|
||||
|
||||
**Zweck:** Bild- und Medienverwaltung.
|
||||
|
||||
### Architektur
|
||||
|
||||
**Kein dediziertes Backend.** Picture verwendet:
|
||||
|
||||
- SvelteKit Server-Routes für Backend-Logik
|
||||
- Mana Core für Authentifizierung
|
||||
- Shared Packages aus `/packages`
|
||||
|
||||
```
|
||||
picture/
|
||||
├── apps/
|
||||
│ ├── mobile/ # React Native Expo
|
||||
│ ├── web/ # SvelteKit
|
||||
│ └── landing/ # Astro
|
||||
└── packages/
|
||||
├── design-tokens/ # Design System
|
||||
├── mobile-ui/ # Mobile UI Components
|
||||
└── shared/ # Utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Memoro
|
||||
|
||||
**Pfad:** `/memoro`
|
||||
|
||||
**Zweck:** Legacy-Content und Memory-Preservation.
|
||||
|
||||
### Architektur
|
||||
|
||||
**Kein dediziertes Backend.** Memoro verwendet:
|
||||
|
||||
- SvelteKit Server-Routes
|
||||
- Mana Core für Authentifizierung
|
||||
- Supabase (Legacy-Konfiguration vorhanden)
|
||||
|
||||
```
|
||||
memoro/
|
||||
├── apps/
|
||||
│ ├── mobile/
|
||||
│ ├── web/
|
||||
│ └── landing/
|
||||
└── supabase/ # Legacy Supabase Config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Manacore
|
||||
|
||||
**Pfad:** `/manacore`
|
||||
|
||||
**Zweck:** Core-Authentifizierung und Credit-System.
|
||||
|
||||
### Architektur
|
||||
|
||||
Das Manacore-Backend ist **extern gehostet** und nicht Teil des Monorepos:
|
||||
|
||||
- **URL:** `https://mana-core-middleware-111768794939.europe-west3.run.app`
|
||||
- **Integration:** Via `@mana-core/nestjs-integration` Package
|
||||
|
||||
```
|
||||
manacore/
|
||||
├── apps/
|
||||
│ ├── mobile/ # Auth-Flow UI
|
||||
│ ├── web/ # Dashboard
|
||||
│ └── landing/ # Marketing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Packages für Backend
|
||||
|
||||
### @manacore/manadeck-database
|
||||
|
||||
PostgreSQL-Datenbankschema für Manadeck.
|
||||
|
||||
```
|
||||
packages/manadeck-database/
|
||||
├── src/
|
||||
│ ├── schema/ # Drizzle Schema
|
||||
│ ├── client.ts # DB Client
|
||||
│ └── index.ts # Exports
|
||||
├── drizzle.config.ts
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### @manacore/uload-database
|
||||
|
||||
PostgreSQL-Datenbankschema für Uload URL-Shortener.
|
||||
|
||||
```
|
||||
packages/uload-database/
|
||||
├── src/
|
||||
│ ├── schema/
|
||||
│ │ ├── users.ts
|
||||
│ │ ├── links.ts
|
||||
│ │ ├── clicks.ts
|
||||
│ │ ├── tags.ts
|
||||
│ │ ├── workspaces.ts
|
||||
│ │ ├── accounts.ts
|
||||
│ │ └── relations.ts
|
||||
│ ├── client.ts # DB Client
|
||||
│ └── index.ts # Exports
|
||||
├── drizzle.config.ts
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### @mana-core/nestjs-integration
|
||||
|
||||
Externe Dependency für Backend-Integration:
|
||||
|
||||
```typescript
|
||||
// Installation via git
|
||||
"@mana-core/nestjs-integration": "git+https://github.com/mana-core/nestjs-integration.git"
|
||||
```
|
||||
|
||||
**Bereitgestellte Features:**
|
||||
- `AuthGuard` - JWT-Authentifizierung
|
||||
- `@CurrentUser()` - User-Context Decorator
|
||||
- `CreditClientService` - Credit-Operationen
|
||||
- Konfigurationsmodule
|
||||
|
||||
---
|
||||
|
||||
## Authentifizierungs-Pattern
|
||||
|
||||
Alle Projekte nutzen zentrale **Mana Core Authentifizierung**:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │────▶│ Project Backend │────▶│ Mana Core │
|
||||
│ (Web/Mobile) │ │ (NestJS/etc.) │ │ Middleware │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
shared-auth AuthGuard JWT Validation
|
||||
shared-auth-ui @CurrentUser Credit Service
|
||||
shared-auth-stores CreditClient User Management
|
||||
```
|
||||
|
||||
### Frontend-Integration
|
||||
|
||||
```typescript
|
||||
// Shared Auth Store (Svelte)
|
||||
import { authStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
// Login
|
||||
await authStore.login(email, password);
|
||||
|
||||
// Token für API-Requests
|
||||
const token = authStore.getAccessToken();
|
||||
```
|
||||
|
||||
### Backend-Integration
|
||||
|
||||
```typescript
|
||||
// NestJS Module Setup
|
||||
@Module({
|
||||
imports: [
|
||||
ManaCoreModule.forRoot({
|
||||
serviceKey: process.env.MANA_CORE_SERVICE_KEY,
|
||||
baseUrl: process.env.MANA_CORE_URL,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Datenbank-Migrationen
|
||||
|
||||
### Manadeck (Drizzle)
|
||||
|
||||
```bash
|
||||
# Migration generieren
|
||||
pnpm --filter @manacore/manadeck-database drizzle-kit generate
|
||||
|
||||
# Migration ausführen
|
||||
pnpm --filter @manacore/manadeck-database drizzle-kit push
|
||||
```
|
||||
|
||||
### Maerchenzauber (Supabase)
|
||||
|
||||
```bash
|
||||
# Supabase CLI
|
||||
supabase migration new <name>
|
||||
supabase db push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
### Maerchenzauber Backend
|
||||
|
||||
```env
|
||||
# Supabase
|
||||
SUPABASE_URL=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Mana Core
|
||||
MANA_CORE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app
|
||||
MANA_CORE_SERVICE_KEY=
|
||||
|
||||
# AI Services
|
||||
MAERCHENZAUBER_AZURE_OPENAI_ENDPOINT=
|
||||
AZURE_OPENAI_API_KEY=
|
||||
GOOGLE_GEMINI_API_KEY=
|
||||
REPLICATE_API_TOKEN=
|
||||
```
|
||||
|
||||
### Manadeck Backend
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/manadeck
|
||||
|
||||
# Mana Core
|
||||
MANA_CORE_URL=
|
||||
MANA_CORE_SERVICE_KEY=
|
||||
|
||||
# AI
|
||||
GOOGLE_GEMINI_API_KEY=
|
||||
|
||||
# Server
|
||||
PORT=8080
|
||||
```
|
||||
|
||||
### Uload
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=postgresql://...
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# PocketBase
|
||||
POCKETBASE_URL=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### Maerchenzauber Backend
|
||||
|
||||
```bash
|
||||
cd maerchenzauber/apps/backend
|
||||
pnpm install
|
||||
pnpm run start:dev
|
||||
# Läuft auf Port 3002
|
||||
```
|
||||
|
||||
### Manadeck Backend
|
||||
|
||||
```bash
|
||||
# 1. Datenbank starten
|
||||
cd packages/manadeck-database
|
||||
docker-compose up -d
|
||||
|
||||
# 2. Backend starten
|
||||
cd manadeck/apps/backend
|
||||
pnpm install
|
||||
pnpm run start:dev
|
||||
# Läuft auf Port 8080
|
||||
```
|
||||
|
||||
### Uload
|
||||
|
||||
```bash
|
||||
cd uload
|
||||
docker-compose up -d # PostgreSQL + Redis
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Das Manacore Monorepo verwendet verschiedene Backend-Strategien:
|
||||
|
||||
1. **Full Backend (NestJS):** Maerchenzauber, Manadeck - Für komplexe Geschäftslogik und AI-Integration
|
||||
2. **Embedded Database (PocketBase):** Uload - Für einfache CRUD-Operationen
|
||||
3. **Frontend-only:** Picture, Memoro - Server-Routes in SvelteKit
|
||||
4. **External Backend:** Manacore - Zentrale Auth/Credit-Services
|
||||
|
||||
Alle Projekte teilen sich:
|
||||
- Gemeinsame Authentifizierung via Mana Core
|
||||
- Shared Packages für UI, Auth, Types
|
||||
- Einheitliches Deployment-Pattern (Docker + Cloud Run)
|
||||
|
||||
---
|
||||
|
||||
## Vereinheitlichungs-Roadmap
|
||||
|
||||
### Aktuelle Fragmentierung
|
||||
|
||||
| Aspekt | Maerchenzauber | Manadeck | Uload |
|
||||
|--------|----------------|----------|-------|
|
||||
| Framework | NestJS v10 | NestJS v11 | PocketBase |
|
||||
| Datenbank | Supabase | PostgreSQL | PocketBase + PG |
|
||||
| ORM | @supabase/js | Drizzle | Drizzle |
|
||||
| Auth | Mana Core | Mana Core | PocketBase + Mana Core |
|
||||
|
||||
---
|
||||
|
||||
### Strategie 1: Shared NestJS Backend Package
|
||||
|
||||
**Ziel:** Ein gemeinsames `@manacore/shared-backend` Package mit wiederverwendbaren Modulen.
|
||||
|
||||
```
|
||||
packages/shared-backend/
|
||||
├── src/
|
||||
│ ├── auth/
|
||||
│ │ ├── auth.module.ts
|
||||
│ │ ├── auth.guard.ts
|
||||
│ │ └── current-user.decorator.ts
|
||||
│ ├── database/
|
||||
│ │ ├── database.module.ts
|
||||
│ │ ├── drizzle.provider.ts
|
||||
│ │ └── base.repository.ts
|
||||
│ ├── health/
|
||||
│ │ └── health.module.ts
|
||||
│ ├── credits/
|
||||
│ │ └── credits.module.ts
|
||||
│ └── common/
|
||||
│ ├── filters/
|
||||
│ ├── interceptors/
|
||||
│ └── pipes/
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Einheitliche Auth-Integration
|
||||
- Wiederverwendbare Module
|
||||
- Konsistente Error-Handling
|
||||
|
||||
**Aufwand:** Mittel
|
||||
|
||||
---
|
||||
|
||||
### Strategie 2: Einheitliche Datenbank-Strategie
|
||||
|
||||
#### Option A: Alles auf Drizzle + PostgreSQL (Empfohlen)
|
||||
|
||||
```typescript
|
||||
// packages/shared-database/src/base-schema.ts
|
||||
export const baseColumns = {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
userId: text('user_id').notNull(),
|
||||
};
|
||||
|
||||
// Projekt-spezifische Erweiterung
|
||||
// maerchenzauber/database/schema/characters.ts
|
||||
import { baseColumns } from '@manacore/shared-database';
|
||||
|
||||
export const characters = pgTable('characters', {
|
||||
...baseColumns,
|
||||
name: text('name').notNull(),
|
||||
traits: jsonb('traits'),
|
||||
});
|
||||
```
|
||||
|
||||
**Migration von Supabase:**
|
||||
- Supabase ist PostgreSQL → Schema kann übernommen werden
|
||||
- RLS-Policies in Application-Layer verschieben
|
||||
- Storage → S3/Cloudflare R2
|
||||
|
||||
#### Option B: Alles auf Supabase
|
||||
|
||||
```typescript
|
||||
// packages/shared-supabase/src/client.ts
|
||||
export const createProjectClient = (project: 'maerchenzauber' | 'manadeck' | 'uload') => {
|
||||
return createClient(
|
||||
process.env[`${project.toUpperCase()}_SUPABASE_URL`],
|
||||
process.env[`${project.toUpperCase()}_SUPABASE_KEY`]
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Vorteile Supabase:**
|
||||
- Eingebaute Auth (optional nutzbar)
|
||||
- Storage inklusive
|
||||
- Realtime-Subscriptions
|
||||
- Edge Functions möglich
|
||||
|
||||
**Nachteile Supabase:**
|
||||
- Vendor Lock-in
|
||||
- Weniger Kontrolle über Schema
|
||||
|
||||
**Empfehlung:** Drizzle + PostgreSQL wegen Type-Safety, moderner API und keinem Vendor Lock-in.
|
||||
|
||||
---
|
||||
|
||||
### Strategie 3: Einheitliche Monorepo Backend Struktur
|
||||
|
||||
**Ziel-Architektur:**
|
||||
|
||||
```
|
||||
packages/
|
||||
├── shared-backend/ # Gemeinsame NestJS Module
|
||||
│ ├── auth/
|
||||
│ ├── database/
|
||||
│ ├── health/
|
||||
│ └── credits/
|
||||
├── shared-database/ # Drizzle Basis-Schema
|
||||
│ ├── base-schema.ts
|
||||
│ ├── migrations/
|
||||
│ └── client.ts
|
||||
├── maerchenzauber-database/ # Projekt-Schema
|
||||
├── manadeck-database/ # ✓ Existiert bereits
|
||||
└── uload-database/ # Neu
|
||||
|
||||
apps/
|
||||
├── maerchenzauber-backend/ # Nutzt shared-backend
|
||||
├── manadeck-backend/ # Nutzt shared-backend
|
||||
└── uload-backend/ # Neues NestJS Backend (ersetzt PocketBase)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Strategie 4: Shared Backend als Service-Layer
|
||||
|
||||
**Ziel:** Gemeinsamer Service-Layer, projekt-spezifische Controller.
|
||||
|
||||
```typescript
|
||||
// packages/shared-backend/src/services/ai.service.ts
|
||||
@Injectable()
|
||||
export class BaseAiService {
|
||||
constructor(
|
||||
private gemini: GeminiClient,
|
||||
private credits: CreditService,
|
||||
) {}
|
||||
|
||||
protected async generateWithCredits<T>(
|
||||
userId: string,
|
||||
operation: string,
|
||||
generator: () => Promise<T>
|
||||
): Promise<T> {
|
||||
await this.credits.check(userId, operation);
|
||||
const result = await generator();
|
||||
await this.credits.deduct(userId, operation);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// maerchenzauber/backend/src/story/story.service.ts
|
||||
@Injectable()
|
||||
export class StoryService extends BaseAiService {
|
||||
async generateStory(userId: string, input: StoryInput) {
|
||||
return this.generateWithCredits(userId, 'STORY_GENERATION', async () => {
|
||||
// Projekt-spezifische Logik
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Strategie 5: API-Gateway Pattern (Optional)
|
||||
|
||||
**Ziel:** Ein zentrales Gateway vor allen Backends.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ API Gateway │
|
||||
│ (Kong/Traefik) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ Maerchenzauber│ │ Manadeck │ │ Uload │
|
||||
│ Backend │ │ Backend │ │ Backend │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Zentrale Auth-Validierung
|
||||
- Rate Limiting
|
||||
- Request Logging
|
||||
- Einheitliche API-Struktur
|
||||
|
||||
**Aufwand:** Hoch - Empfohlen erst bei Skalierungsbedarf
|
||||
|
||||
---
|
||||
|
||||
### Empfohlene Implementierungsreihenfolge
|
||||
|
||||
#### Phase 1: Shared Backend Package
|
||||
|
||||
**Priorität:** Hoch
|
||||
**Aufwand:** 2-3 Wochen
|
||||
|
||||
Neues Package `packages/shared-backend/` mit:
|
||||
- Auth Module (wraps @mana-core/nestjs-integration)
|
||||
- Health Module
|
||||
- Credits Module
|
||||
- Base Repository Pattern
|
||||
- Common Decorators, Guards, Filters
|
||||
|
||||
#### Phase 2: Datenbank-Vereinheitlichung
|
||||
|
||||
**Priorität:** Hoch
|
||||
**Aufwand:** 3-4 Wochen
|
||||
|
||||
1. `packages/shared-database/` mit Drizzle Basis-Schema erstellen
|
||||
2. Maerchenzauber von Supabase auf Drizzle migrieren
|
||||
3. Uload PocketBase durch PostgreSQL + Drizzle ersetzen
|
||||
|
||||
#### Phase 3: Uload Backend Neubau (Optional)
|
||||
|
||||
**Priorität:** Mittel
|
||||
**Aufwand:** 2-3 Wochen
|
||||
|
||||
PocketBase → NestJS Migration:
|
||||
- Konsistenz mit anderen Projekten
|
||||
- Bessere Integration mit Mana Core
|
||||
- Einheitliches Deployment
|
||||
|
||||
---
|
||||
|
||||
### Optionen-Vergleich
|
||||
|
||||
| Option | Aufwand | Benefit | Empfehlung |
|
||||
|--------|---------|---------|------------|
|
||||
| Shared Backend Package | Mittel | Hoch | ✅ Priorität 1 |
|
||||
| Drizzle überall | Mittel-Hoch | Hoch | ✅ Priorität 2 |
|
||||
| Uload auf NestJS | Hoch | Mittel | ⚡ Optional |
|
||||
| API Gateway | Sehr Hoch | Mittel | ⏳ Später |
|
||||
|
||||
---
|
||||
|
||||
### Quick Wins (sofort umsetzbar)
|
||||
|
||||
1. **NestJS Version angleichen** → Alle auf v11
|
||||
2. **Einheitliche Health-Endpoints** → `/health`, `/health/ready`
|
||||
3. **Gemeinsame ESLint/Prettier Config** → `@manacore/eslint-config-backend`
|
||||
4. **Einheitliche Error-Response-Struktur:**
|
||||
|
||||
```typescript
|
||||
// Einheitliches Error-Format für alle Backends
|
||||
interface ApiError {
|
||||
statusCode: number;
|
||||
error: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
}
|
||||
```
|
||||
|
||||
5. **Einheitliche Logging-Struktur:**
|
||||
|
||||
```typescript
|
||||
// packages/shared-backend/src/logging/logger.service.ts
|
||||
@Injectable()
|
||||
export class AppLogger {
|
||||
log(context: string, message: string, meta?: Record<string, any>) {
|
||||
console.log(JSON.stringify({
|
||||
level: 'info',
|
||||
context,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...meta,
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Ziel-Architektur nach Vereinheitlichung
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Shared Packages │
|
||||
├─────────────────┬─────────────────┬─────────────────────────┤
|
||||
│ shared-backend │ shared-database │ shared-types │
|
||||
│ - AuthModule │ - baseColumns │ - ApiError │
|
||||
│ - HealthModule │ - drizzleClient │ - User │
|
||||
│ - CreditsModule │ - migrations │ - CreditOperation │
|
||||
│ - BaseRepo │ │ │
|
||||
└────────┬────────┴────────┬────────┴────────┬────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Maerchenzauber │ │ Manadeck │ │ Uload │
|
||||
│ Backend │ │ Backend │ │ Backend │
|
||||
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
|
||||
│ NestJS v11 │ │ NestJS v11 │ │ NestJS v11 │
|
||||
│ PostgreSQL │ │ PostgreSQL │ │ PostgreSQL │
|
||||
│ Drizzle ORM │ │ Drizzle ORM │ │ Drizzle ORM │
|
||||
│ Port: 3002 │ │ Port: 8080 │ │ Port: 3003 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└─────────────────┼─────────────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Mana Core │
|
||||
│ (Auth + Credits) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
226
docs/I18N.md
Normal file
226
docs/I18N.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Internationalization (i18n) im Manacore Monorepo
|
||||
|
||||
Alle Web-Projekte im Monorepo verwenden **svelte-i18n** für die Internationalisierung. Diese Dokumentation beschreibt die einheitliche Implementierung.
|
||||
|
||||
## Übersicht
|
||||
|
||||
| Projekt | Sprachen | Default |
|
||||
|---------|----------|---------|
|
||||
| maerchenzauber | de, en, es, fr, it | de |
|
||||
| manacore | de, en, es, fr, it | de |
|
||||
| manadeck | de, en, es, fr, it | de |
|
||||
| memoro | de, en, es, fr, it | de |
|
||||
| picture | de, en | de |
|
||||
| uload | de, en, es, fr, it | en |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
Jedes Web-Projekt hat die folgende i18n-Struktur:
|
||||
|
||||
```
|
||||
apps/web/
|
||||
└── src/
|
||||
└── lib/
|
||||
└── i18n/
|
||||
├── index.ts # Initialisierung & Konfiguration
|
||||
└── locales/
|
||||
├── de.json # Deutsche Übersetzungen
|
||||
├── en.json # Englische Übersetzungen
|
||||
├── es.json # Spanische Übersetzungen (optional)
|
||||
├── fr.json # Französische Übersetzungen (optional)
|
||||
└── it.json # Italienische Übersetzungen (optional)
|
||||
```
|
||||
|
||||
## Implementierung
|
||||
|
||||
### 1. index.ts - Konfiguration
|
||||
|
||||
```typescript
|
||||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
// Sprachen registrieren
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
// ... weitere Sprachen
|
||||
|
||||
// Unterstützte Sprachen
|
||||
export const supportedLocales = ['de', 'en', 'es', 'fr', 'it'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
const defaultLocale = 'de';
|
||||
|
||||
// Initiale Sprache ermitteln
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
// 1. localStorage prüfen
|
||||
const stored = localStorage.getItem('locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
|
||||
// 2. Browser-Sprache prüfen
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
// i18n initialisieren
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale()
|
||||
});
|
||||
|
||||
// Sprache ändern und speichern
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export { waitLocale };
|
||||
```
|
||||
|
||||
### 2. Locale-Dateien (JSON)
|
||||
|
||||
Die Übersetzungen werden als flache oder verschachtelte JSON-Objekte gespeichert:
|
||||
|
||||
```json
|
||||
{
|
||||
"nav_login": "Anmelden",
|
||||
"nav_register": "Registrieren",
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Verwendung in Svelte-Komponenten
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<!-- Einfacher Key -->
|
||||
<button>{$t('nav_login')}</button>
|
||||
|
||||
<!-- Verschachtelter Key -->
|
||||
<button>{$t('common.save')}</button>
|
||||
|
||||
<!-- Mit Parametern -->
|
||||
<p>{$t('welcome', { values: { name: 'Max' } })}</p>
|
||||
```
|
||||
|
||||
### 4. Language Switcher
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
||||
const languages = [
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#each languages as lang}
|
||||
<button
|
||||
onclick={() => setLocale(lang.code)}
|
||||
class:active={$locale === lang.code}
|
||||
>
|
||||
{lang.flag} {lang.name}
|
||||
</button>
|
||||
{/each}
|
||||
```
|
||||
|
||||
## Neues Projekt einrichten
|
||||
|
||||
1. **svelte-i18n installieren:**
|
||||
```bash
|
||||
pnpm add svelte-i18n
|
||||
```
|
||||
|
||||
2. **i18n-Ordner erstellen:**
|
||||
```bash
|
||||
mkdir -p src/lib/i18n/locales
|
||||
```
|
||||
|
||||
3. **index.ts kopieren** von einem bestehenden Projekt und anpassen
|
||||
|
||||
4. **Locale-Dateien erstellen** (de.json, en.json, etc.)
|
||||
|
||||
5. **In +layout.svelte importieren:**
|
||||
```svelte
|
||||
<script>
|
||||
import '$lib/i18n';
|
||||
</script>
|
||||
```
|
||||
|
||||
## Neue Übersetzung hinzufügen
|
||||
|
||||
1. Key in allen Locale-Dateien hinzufügen:
|
||||
```json
|
||||
// de.json
|
||||
{ "new_key": "Neue Übersetzung" }
|
||||
|
||||
// en.json
|
||||
{ "new_key": "New translation" }
|
||||
```
|
||||
|
||||
2. In Komponente verwenden:
|
||||
```svelte
|
||||
{$t('new_key')}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Keys:** Snake_case verwenden (`nav_login`, `home_title`)
|
||||
- **Namespacing:** Präfixe für Bereiche (`auth_`, `nav_`, `home_`, `toast_`)
|
||||
- **Fallback:** Immer `fallbackLocale` setzen
|
||||
- **SSR:** `waitLocale()` in Server-Load-Funktionen verwenden
|
||||
- **Konsistenz:** Gleiche Keys in allen Projekten für gemeinsame Elemente
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Übersetzung wird nicht angezeigt
|
||||
|
||||
1. Prüfen ob Key in allen Locale-Dateien existiert
|
||||
2. Prüfen ob `$lib/i18n` importiert wurde
|
||||
3. Browser-Cache leeren
|
||||
|
||||
### SSR-Fehler
|
||||
|
||||
```typescript
|
||||
// In +layout.ts oder +page.ts
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
|
||||
export const load = async () => {
|
||||
await waitLocale();
|
||||
return {};
|
||||
};
|
||||
```
|
||||
|
||||
### Sprache wechselt nicht
|
||||
|
||||
Prüfen ob `locale.set()` aufgerufen wird und localStorage Zugriff erlaubt ist.
|
||||
|
||||
## Shared i18n Package
|
||||
|
||||
Für gemeinsame Übersetzungen zwischen Projekten kann das Package `@manacore/shared-i18n` verwendet werden (wenn vorhanden).
|
||||
|
||||
```typescript
|
||||
// In index.ts
|
||||
import sharedTranslations from '@manacore/shared-i18n/de.json';
|
||||
|
||||
register('de', async () => {
|
||||
const local = await import('./locales/de.json');
|
||||
return { ...sharedTranslations, ...local.default };
|
||||
});
|
||||
```
|
||||
684
docs/SELF-HOSTING-GUIDE.md
Normal file
684
docs/SELF-HOSTING-GUIDE.md
Normal file
|
|
@ -0,0 +1,684 @@
|
|||
# Self-Hosting Guide - Manacore Monorepo
|
||||
|
||||
Komplette Anleitung zum Hosten aller Projekte auf eigener Infrastruktur (VPS).
|
||||
|
||||
## Projektübersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MANACORE MONOREPO │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ MAERCHENZAUBER│ │ MANACORE │ │ MANADECK │ │ MEMORO │ │
|
||||
│ │ (Storyteller)│ │ (Auth Hub) │ │ (Deck App) │ │ (Voice App) │ │
|
||||
│ ├──────────────┤ ├──────────────┤ ├──────────────┤ ├──────────────┤ │
|
||||
│ │ • Web │ │ • Web │ │ • Web │ │ • Web │ │
|
||||
│ │ • Mobile │ │ • Mobile │ │ • Mobile │ │ • Mobile │ │
|
||||
│ │ • Landing │ │ • Landing │ │ • Landing │ │ • Landing │ │
|
||||
│ │ • Backend │ │ │ │ • Backend │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ PICTURE │ │ ULOAD │ │
|
||||
│ │ (Canvas App) │ │(URL Shortener)│ │
|
||||
│ ├──────────────┤ ├──────────────┤ │
|
||||
│ │ • Web │ │ • Web │ │
|
||||
│ │ • Mobile │ │ │ │
|
||||
│ │ • Landing │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Stack pro Projekt
|
||||
|
||||
| Projekt | Web App | Landing | Backend | Mobile | Datenbank |
|
||||
|---------|---------|---------|---------|--------|-----------|
|
||||
| **Maerchenzauber** | SvelteKit | Astro | NestJS | Expo | Supabase |
|
||||
| **Manacore** | SvelteKit | Astro | - | Expo | Supabase |
|
||||
| **Manadeck** | SvelteKit | Astro | NestJS | Expo | PostgreSQL |
|
||||
| **Memoro** | SvelteKit | Astro | - | Expo | Supabase |
|
||||
| **Picture** | SvelteKit | Astro | - | Expo | Supabase |
|
||||
| **uLoad** | SvelteKit | - | - | - | PostgreSQL + Redis |
|
||||
|
||||
---
|
||||
|
||||
## Deployment-Optionen im Überblick
|
||||
|
||||
### Option A: Single VPS mit Coolify (Empfohlen für Start)
|
||||
- **Kosten:** ~€15-30/Monat
|
||||
- **Komplexität:** Niedrig
|
||||
- **Skalierung:** Begrenzt
|
||||
|
||||
### Option B: Multi-VPS mit Coolify
|
||||
- **Kosten:** ~€50-100/Monat
|
||||
- **Komplexität:** Mittel
|
||||
- **Skalierung:** Gut
|
||||
|
||||
### Option C: Kubernetes (K3s)
|
||||
- **Kosten:** ~€30-80/Monat
|
||||
- **Komplexität:** Hoch
|
||||
- **Skalierung:** Sehr gut
|
||||
|
||||
### Option D: Hybrid (Self-Hosted + Managed)
|
||||
- **Kosten:** ~€20-50/Monat + Supabase
|
||||
- **Komplexität:** Niedrig-Mittel
|
||||
- **Skalierung:** Flexibel
|
||||
|
||||
---
|
||||
|
||||
# Option A: Single VPS mit Coolify
|
||||
|
||||
Die einfachste Lösung für den Start. Alle Services auf einem Server.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Hetzner VPS (CX31+) │
|
||||
│ 4 vCPU, 8GB RAM, 80GB │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ COOLIFY │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ TRAEFIK │ │
|
||||
│ │ (Reverse Proxy + SSL) │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌──────┴──────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ │
|
||||
│ │ Web Apps │ │ Backends │ │ Databases │ │ Landing │ │
|
||||
│ │ (Node.js) │ │ (NestJS) │ │ (PG+Redis)│ │ (Astro) │ │
|
||||
│ │ :3000-3005 │ │ :4000-4001│ │ :5432,6379│ │ :8080+ │ │
|
||||
│ └─────────────┘ └───────────┘ └───────────┘ └───────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Ressourcen-Anforderungen
|
||||
|
||||
| Komponente | RAM | CPU | Disk |
|
||||
|------------|-----|-----|------|
|
||||
| PostgreSQL | 1GB | 0.5 | 10GB |
|
||||
| Redis | 256MB | 0.2 | 1GB |
|
||||
| Coolify | 512MB | 0.3 | 5GB |
|
||||
| Traefik | 128MB | 0.1 | - |
|
||||
| Pro Web App | 256MB | 0.3 | - |
|
||||
| Pro Backend | 512MB | 0.5 | - |
|
||||
| Pro Landing | 64MB | 0.1 | - |
|
||||
| **Gesamt (alle)** | **~6GB** | **~4** | **~30GB** |
|
||||
|
||||
**Empfohlener Server:** Hetzner CX31 (4 vCPU, 8GB RAM, 80GB) - €8.98/Monat
|
||||
|
||||
## Schritt-für-Schritt Setup
|
||||
|
||||
### 1. VPS bestellen und Coolify installieren
|
||||
|
||||
```bash
|
||||
# SSH zum Server
|
||||
ssh root@YOUR-IP
|
||||
|
||||
# System updaten
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# Coolify installieren
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
```
|
||||
|
||||
### 2. Datenbank-Services erstellen
|
||||
|
||||
**PostgreSQL:**
|
||||
```
|
||||
Name: shared-postgres
|
||||
Version: 16-alpine
|
||||
Databases: manacore, manadeck, uload
|
||||
```
|
||||
|
||||
**Redis:**
|
||||
```
|
||||
Name: shared-redis
|
||||
Version: 7-alpine
|
||||
```
|
||||
|
||||
### 3. Projekte deployen
|
||||
|
||||
#### Deployment-Reihenfolge (wichtig!)
|
||||
|
||||
1. **Datenbanken** (PostgreSQL, Redis)
|
||||
2. **Backends** (Maerchenzauber, Manadeck)
|
||||
3. **Web Apps** (alle)
|
||||
4. **Landing Pages** (alle)
|
||||
|
||||
#### Konfiguration pro Projekt
|
||||
|
||||
**Alle SvelteKit Web Apps:**
|
||||
```
|
||||
Base Directory: /
|
||||
Dockerfile: {projekt}/apps/web/Dockerfile # Falls vorhanden
|
||||
oder
|
||||
Build Pack: Nixpacks
|
||||
Build Command: cd {projekt}/apps/web && pnpm build
|
||||
Start Command: cd {projekt}/apps/web && node build
|
||||
Port: 3000
|
||||
```
|
||||
|
||||
**Alle Astro Landing Pages:**
|
||||
```
|
||||
Build Pack: Static
|
||||
Base Directory: {projekt}/apps/landing
|
||||
Build Command: pnpm build
|
||||
Publish Directory: dist
|
||||
```
|
||||
|
||||
**NestJS Backends:**
|
||||
```
|
||||
Base Directory: /
|
||||
Dockerfile: {projekt}/apps/backend/Dockerfile
|
||||
oder
|
||||
Dockerfile: {projekt}/backend/Dockerfile
|
||||
Port: 4000
|
||||
```
|
||||
|
||||
### 4. Domain-Mapping
|
||||
|
||||
| Service | Domain | Port |
|
||||
|---------|--------|------|
|
||||
| uload-web | ulo.ad | 3000 |
|
||||
| maerchenzauber-web | app.maerchenzauber.de | 3001 |
|
||||
| maerchenzauber-landing | maerchenzauber.de | 8080 |
|
||||
| maerchenzauber-backend | api.maerchenzauber.de | 4000 |
|
||||
| manacore-web | app.manacore.io | 3002 |
|
||||
| manacore-landing | manacore.io | 8081 |
|
||||
| manadeck-web | app.manadeck.de | 3003 |
|
||||
| manadeck-landing | manadeck.de | 8082 |
|
||||
| manadeck-backend | api.manadeck.de | 4001 |
|
||||
| memoro-web | app.memoro.ai | 3004 |
|
||||
| memoro-landing | memoro.ai | 8083 |
|
||||
| picture-web | app.picture.io | 3005 |
|
||||
| picture-landing | picture.io | 8084 |
|
||||
|
||||
---
|
||||
|
||||
# Option B: Multi-VPS mit Coolify
|
||||
|
||||
Bessere Isolation und Skalierung durch mehrere Server.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ DNS / CDN │
|
||||
│ (Cloudflare) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────────────────┼───────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ VPS 1 │ │ VPS 2 │ │ VPS 3 │
|
||||
│ (Apps) │ │ (Backends) │ │ (Databases) │
|
||||
│ CX21 │ │ CX21 │ │ CX31 │
|
||||
├───────────────┤ ├───────────────┤ ├───────────────┤
|
||||
│ • Web Apps │ ◄─────► │ • NestJS APIs │ ◄─────► │ • PostgreSQL │
|
||||
│ • Landing │ │ • Workers │ │ • Redis │
|
||||
│ Pages │ │ │ │ • Backups │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
## Server-Aufteilung
|
||||
|
||||
### VPS 1: Frontend (CX21 - €4.49/Monat)
|
||||
- Alle SvelteKit Web Apps
|
||||
- Alle Astro Landing Pages
|
||||
- Traefik Reverse Proxy
|
||||
|
||||
### VPS 2: Backends (CX21 - €4.49/Monat)
|
||||
- Maerchenzauber NestJS Backend
|
||||
- Manadeck NestJS Backend
|
||||
- Background Workers
|
||||
|
||||
### VPS 3: Datenbanken (CX31 - €8.98/Monat)
|
||||
- PostgreSQL (shared)
|
||||
- Redis (shared)
|
||||
- Automated Backups
|
||||
|
||||
**Gesamtkosten:** ~€18/Monat
|
||||
|
||||
## Einrichtung
|
||||
|
||||
### VPS 3 (Datenbanken) zuerst
|
||||
|
||||
```bash
|
||||
# Coolify installieren
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
|
||||
# PostgreSQL mit externem Zugriff
|
||||
# In Coolify: Network → Enable External Access
|
||||
```
|
||||
|
||||
### VPS 2 (Backends)
|
||||
|
||||
```bash
|
||||
# Coolify installieren
|
||||
# Backends deployen mit DATABASE_URL zu VPS 3
|
||||
```
|
||||
|
||||
### VPS 1 (Frontends)
|
||||
|
||||
```bash
|
||||
# Coolify installieren
|
||||
# Web Apps deployen mit API_URL zu VPS 2
|
||||
```
|
||||
|
||||
## Netzwerk-Sicherheit
|
||||
|
||||
```bash
|
||||
# Auf VPS 3 (Datenbanken): Nur VPS 1+2 erlauben
|
||||
ufw allow from VPS1-IP to any port 5432
|
||||
ufw allow from VPS2-IP to any port 5432
|
||||
ufw allow from VPS1-IP to any port 6379
|
||||
ufw allow from VPS2-IP to any port 6379
|
||||
ufw deny 5432
|
||||
ufw deny 6379
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Option C: Kubernetes mit K3s
|
||||
|
||||
Für maximale Skalierung und Automatisierung.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ K3s Cluster │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ INGRESS (Traefik) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼───────────────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
||||
│ │ │ Namespace │ │ Namespace │ │ Namespace │ │ │
|
||||
│ │ │ uload │ │ maerchen- │ │ manadeck │ │ │
|
||||
│ │ │ │ │ zauber │ │ │ │ │
|
||||
│ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │
|
||||
│ │ │ │ web:3 │ │ │ │ web:2 │ │ │ │ web:2 │ │ │ │
|
||||
│ │ │ │ replicas│ │ │ │ backend │ │ │ │ backend │ │ │ │
|
||||
│ │ │ └─────────┘ │ │ │ landing │ │ │ │ landing │ │ │ │
|
||||
│ │ └─────────────┘ │ └─────────┘ │ │ └─────────┘ │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Shared Services │ │ │
|
||||
│ │ │ PostgreSQL (StatefulSet) │ Redis (StatefulSet) │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Node 1 (CX21) Node 2 (CX21) Node 3 (CX21) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## K3s Setup
|
||||
|
||||
### Master Node installieren
|
||||
|
||||
```bash
|
||||
# Auf Node 1
|
||||
curl -sfL https://get.k3s.io | sh -
|
||||
|
||||
# Token für Worker holen
|
||||
cat /var/lib/rancher/k3s/server/node-token
|
||||
```
|
||||
|
||||
### Worker Nodes hinzufügen
|
||||
|
||||
```bash
|
||||
# Auf Node 2 und 3
|
||||
curl -sfL https://get.k3s.io | K3S_URL=https://NODE1-IP:6443 K3S_TOKEN=TOKEN sh -
|
||||
```
|
||||
|
||||
### Helm Charts deployen
|
||||
|
||||
```yaml
|
||||
# values-uload.yaml
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: ghcr.io/your-org/uload-web
|
||||
tag: latest
|
||||
service:
|
||||
port: 3000
|
||||
ingress:
|
||||
enabled: true
|
||||
hosts:
|
||||
- host: ulo.ad
|
||||
paths:
|
||||
- path: /
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: db-credentials
|
||||
key: url
|
||||
```
|
||||
|
||||
```bash
|
||||
helm install uload ./charts/sveltekit -f values-uload.yaml
|
||||
```
|
||||
|
||||
## Vorteile K8s
|
||||
|
||||
- Auto-Scaling bei Last
|
||||
- Rolling Updates ohne Downtime
|
||||
- Self-Healing bei Ausfällen
|
||||
- Resource Limits pro App
|
||||
|
||||
## Nachteile K8s
|
||||
|
||||
- Höhere Komplexität
|
||||
- Mehr Overhead (RAM für K8s selbst)
|
||||
- Lernkurve
|
||||
|
||||
---
|
||||
|
||||
# Option D: Hybrid (Self-Hosted + Managed)
|
||||
|
||||
Kombination aus Self-Hosting und Managed Services für beste Balance.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MANAGED SERVICES │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ SUPABASE │ │ CLOUDFLARE │ │ VERCEL/ │ │
|
||||
│ │ (Database) │ │ (CDN) │ │ NETLIFY │ │
|
||||
│ │ PostgreSQL │ │ DNS, Cache │ │ (Landing) │ │
|
||||
│ │ Auth, Store │ │ DDoS Prot. │ │ Static │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼──────────────────┼──────────────────┼──────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SELF-HOSTED (VPS) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Hetzner CX21 │ │
|
||||
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
|
||||
│ │ │ Web Apps │ │ Backends │ │ uLoad │ │ │
|
||||
│ │ │ (SvelteKit)│ │ (NestJS) │ │ + DB │ │ │
|
||||
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Was wo hosten?
|
||||
|
||||
### Managed Services (empfohlen)
|
||||
|
||||
| Service | Anbieter | Kosten | Grund |
|
||||
|---------|----------|--------|-------|
|
||||
| Datenbank | Supabase | Free-$25/M | Auth + Realtime inklusive |
|
||||
| Landing Pages | Vercel/Netlify | Free | CDN + Edge |
|
||||
| CDN | Cloudflare | Free | DDoS + Caching |
|
||||
| Email | Resend | Free-$20/M | Deliverability |
|
||||
| Payments | Stripe | % per Tx | Compliance |
|
||||
|
||||
### Self-Hosted (VPS)
|
||||
|
||||
| Service | Grund |
|
||||
|---------|-------|
|
||||
| Web Apps | Volle Kontrolle, günstiger bei Traffic |
|
||||
| Backends | Custom Code, API Keys |
|
||||
| uLoad | Komplett eigene Infra gewünscht |
|
||||
| Redis | Falls benötigt |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Supabase Projekt erstellen
|
||||
|
||||
Für: Maerchenzauber, Manacore, Memoro, Picture
|
||||
|
||||
```bash
|
||||
# Supabase CLI
|
||||
supabase init
|
||||
supabase db push
|
||||
```
|
||||
|
||||
### 2. Landing Pages auf Vercel
|
||||
|
||||
```bash
|
||||
# In jedem Landing-Projekt
|
||||
cd maerchenzauber/apps/landing
|
||||
vercel deploy --prod
|
||||
```
|
||||
|
||||
### 3. VPS für Web Apps + Backends
|
||||
|
||||
```bash
|
||||
# Coolify auf Hetzner CX21
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
|
||||
# Apps deployen mit Supabase URLs
|
||||
```
|
||||
|
||||
## Kosten-Vergleich
|
||||
|
||||
| Komponente | Full Self-Hosted | Hybrid |
|
||||
|------------|------------------|--------|
|
||||
| VPS | €9-18/Monat | €4.50/Monat |
|
||||
| Supabase | - | Free-€25/Monat |
|
||||
| Vercel | - | Free |
|
||||
| Cloudflare | - | Free |
|
||||
| **Gesamt** | **€9-18/Monat** | **€4.50-30/Monat** |
|
||||
|
||||
---
|
||||
|
||||
# Dockerfiles für alle Projekte
|
||||
|
||||
## SvelteKit Web Apps (Template)
|
||||
|
||||
Erstelle für jedes Projekt ohne Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
# {projekt}/apps/web/Dockerfile
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Monorepo files
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY {projekt}/apps/web/ ./{projekt}/apps/web/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Install and build
|
||||
RUN pnpm install --filter @{projekt}/web... --shamefully-hoist
|
||||
WORKDIR /app/{projekt}/apps/web
|
||||
RUN pnpm build
|
||||
|
||||
# Runner
|
||||
FROM node:20-alpine
|
||||
RUN adduser -D sveltekit
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/{projekt}/apps/web/build ./build
|
||||
COPY --from=builder /app/{projekt}/apps/web/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
USER sveltekit
|
||||
ENV NODE_ENV=production PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
```
|
||||
|
||||
## Astro Landing Pages
|
||||
|
||||
```dockerfile
|
||||
# {projekt}/apps/landing/Dockerfile
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY {projekt}/apps/landing/ ./{projekt}/apps/landing/
|
||||
COPY packages/ ./packages/
|
||||
|
||||
RUN pnpm install --filter @{projekt}/landing... --shamefully-hoist
|
||||
WORKDIR /app/{projekt}/apps/landing
|
||||
RUN pnpm build
|
||||
|
||||
# Nginx for static files
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/{projekt}/apps/landing/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
## NestJS Backends
|
||||
|
||||
Bereits vorhanden in:
|
||||
- `maerchenzauber/apps/backend/Dockerfile`
|
||||
- `manadeck/backend/Dockerfile`
|
||||
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
## Gemeinsame Variablen (alle Projekte)
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
## Supabase-basierte Projekte
|
||||
|
||||
```env
|
||||
# Maerchenzauber, Manacore, Memoro, Picture
|
||||
PUBLIC_SUPABASE_URL=https://xxx.supabase.co
|
||||
PUBLIC_SUPABASE_ANON_KEY=eyJxx...
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJxx... # Nur Backend
|
||||
```
|
||||
|
||||
## PostgreSQL-basierte Projekte
|
||||
|
||||
```env
|
||||
# Manadeck, uLoad
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
```
|
||||
|
||||
## Projekt-spezifische Variablen
|
||||
|
||||
### Maerchenzauber Backend
|
||||
```env
|
||||
AZURE_OPENAI_ENDPOINT=https://xxx.openai.azure.com
|
||||
AZURE_OPENAI_API_KEY=xxx
|
||||
GOOGLE_GEMINI_API_KEY=xxx
|
||||
REPLICATE_API_TOKEN=xxx
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
|
||||
```
|
||||
|
||||
### Manadeck Backend
|
||||
```env
|
||||
GOOGLE_GEMINI_API_KEY=xxx
|
||||
```
|
||||
|
||||
### uLoad
|
||||
```env
|
||||
REDIS_URL=redis://localhost:6379
|
||||
STRIPE_SECRET_KEY=sk_live_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
RESEND_API_KEY=re_xxx
|
||||
R2_ACCESS_KEY_ID=xxx
|
||||
R2_SECRET_ACCESS_KEY=xxx
|
||||
R2_BUCKET_NAME=xxx
|
||||
R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com
|
||||
AUTH_SECRET=xxx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Checkliste: Komplettes Self-Hosting
|
||||
|
||||
## Infrastruktur
|
||||
- [ ] VPS bestellt (Hetzner CX21/CX31)
|
||||
- [ ] SSH-Zugang eingerichtet
|
||||
- [ ] Coolify installiert
|
||||
- [ ] Firewall konfiguriert
|
||||
|
||||
## Datenbanken
|
||||
- [ ] PostgreSQL läuft
|
||||
- [ ] Redis läuft (falls benötigt)
|
||||
- [ ] Backups eingerichtet
|
||||
- [ ] Connection Strings notiert
|
||||
|
||||
## Projekte (für jedes)
|
||||
- [ ] Dockerfile erstellt/geprüft
|
||||
- [ ] Environment Variables gesetzt
|
||||
- [ ] Domain konfiguriert
|
||||
- [ ] SSL-Zertifikat aktiv
|
||||
- [ ] Health-Check funktioniert
|
||||
|
||||
## DNS (für jede Domain)
|
||||
- [ ] A-Record auf Server-IP
|
||||
- [ ] www CNAME (optional)
|
||||
- [ ] Propagation geprüft
|
||||
|
||||
## Monitoring
|
||||
- [ ] Logs erreichbar
|
||||
- [ ] Alerting eingerichtet (optional)
|
||||
- [ ] Uptime-Monitoring (optional)
|
||||
|
||||
## Backups
|
||||
- [ ] Datenbank-Backup automatisiert
|
||||
- [ ] Backup-Test durchgeführt
|
||||
- [ ] Offsite-Backup (optional)
|
||||
|
||||
---
|
||||
|
||||
# Empfehlung
|
||||
|
||||
## Für den Start: Option D (Hybrid)
|
||||
|
||||
1. **Supabase** für Datenbank + Auth (Free Tier)
|
||||
2. **Vercel/Netlify** für Landing Pages (Free)
|
||||
3. **Hetzner CX21** für Web Apps + Backends (€4.50/Monat)
|
||||
4. **Cloudflare** für DNS + CDN (Free)
|
||||
|
||||
**Vorteile:**
|
||||
- Schneller Start
|
||||
- Geringe Kosten
|
||||
- Managed Auth & Realtime
|
||||
- Einfache Skalierung später
|
||||
|
||||
## Für Wachstum: Option B (Multi-VPS)
|
||||
|
||||
Wenn Traffic steigt:
|
||||
1. Datenbanken auf eigenen VPS migrieren
|
||||
2. Frontend/Backend trennen
|
||||
3. Load Balancing hinzufügen
|
||||
|
||||
## Für Enterprise: Option C (Kubernetes)
|
||||
|
||||
Wenn benötigt:
|
||||
- Auto-Scaling
|
||||
- Zero-Downtime Deployments
|
||||
- Multi-Region
|
||||
|
||||
---
|
||||
|
||||
# Support & Links
|
||||
|
||||
- **Coolify Docs:** https://coolify.io/docs
|
||||
- **Hetzner:** https://www.hetzner.com/cloud
|
||||
- **Supabase:** https://supabase.com/docs
|
||||
- **K3s:** https://k3s.io
|
||||
- **Traefik:** https://doc.traefik.io/traefik/
|
||||
446
docs/ULOAD-DEPLOYMENT.md
Normal file
446
docs/ULOAD-DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
# uload Deployment Guide
|
||||
|
||||
Schritt-für-Schritt Anleitung zum Deployment von uload mit Coolify auf Hetzner VPS.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Hetzner VPS │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ Coolify │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ uload │ │ PostgreSQL │ │ Redis │ │ │
|
||||
│ │ │ (Node) │ │ (16) │ │ (7) │ │ │
|
||||
│ │ │ :3000 │ │ :5432 │ │ :6379 │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └────────────────┴──────────────────┘ │ │
|
||||
│ │ Traefik (SSL/Proxy) │ │
|
||||
│ │ :80 / :443 │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
https://ulo.ad
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- [ ] Hetzner VPS (mindestens CX21: 2 vCPU, 4GB RAM, 40GB SSD)
|
||||
- [ ] Domain mit DNS-Zugang (z.B. ulo.ad)
|
||||
- [ ] GitHub Account mit Zugriff auf das Repository
|
||||
- [ ] Accounts für externe Services:
|
||||
- Resend (Email)
|
||||
- Stripe (Payments)
|
||||
- Cloudflare R2 (Storage)
|
||||
|
||||
---
|
||||
|
||||
## Schritt 1: Hetzner VPS einrichten
|
||||
|
||||
### 1.1 Server erstellen
|
||||
|
||||
1. Gehe zu [Hetzner Cloud Console](https://console.hetzner.cloud)
|
||||
2. Erstelle neues Projekt oder wähle bestehendes
|
||||
3. Klicke **Add Server**
|
||||
4. Wähle:
|
||||
- **Location:** Falkenstein oder Nürnberg (DE)
|
||||
- **Image:** Ubuntu 22.04
|
||||
- **Type:** CX21 (2 vCPU, 4GB RAM) oder größer
|
||||
- **SSH Key:** Füge deinen öffentlichen SSH-Key hinzu
|
||||
5. Klicke **Create & Buy Now**
|
||||
6. Notiere die **IP-Adresse**
|
||||
|
||||
### 1.2 Mit Server verbinden
|
||||
|
||||
```bash
|
||||
ssh root@DEINE-SERVER-IP
|
||||
```
|
||||
|
||||
### 1.3 System updaten
|
||||
|
||||
```bash
|
||||
apt update && apt upgrade -y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 2: Coolify installieren
|
||||
|
||||
### 2.1 Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
```
|
||||
|
||||
Die Installation dauert ca. 2-5 Minuten.
|
||||
|
||||
### 2.2 Coolify öffnen
|
||||
|
||||
1. Öffne im Browser: `http://DEINE-SERVER-IP:8000`
|
||||
2. Erstelle Admin-Account (E-Mail + Passwort)
|
||||
3. Wähle **Self-hosted** als Instance Type
|
||||
4. Der Server "localhost" wird automatisch hinzugefügt
|
||||
|
||||
---
|
||||
|
||||
## Schritt 3: PostgreSQL Datenbank erstellen
|
||||
|
||||
### 3.1 In Coolify
|
||||
|
||||
1. Klicke **+ New Resource**
|
||||
2. Wähle **Database**
|
||||
3. Wähle **PostgreSQL**
|
||||
4. Konfiguriere:
|
||||
- **Name:** `uload-postgres`
|
||||
- **Version:** `16-alpine`
|
||||
- **Database Name:** `uload`
|
||||
- **Database User:** `uload`
|
||||
- **Password:** (automatisch generiert oder eigenes)
|
||||
5. Klicke **Start**
|
||||
|
||||
### 3.2 Connection String notieren
|
||||
|
||||
Nach dem Start findest du unter **Connect** die Internal URL:
|
||||
|
||||
```
|
||||
postgresql://uload:PASSWORT@uload-postgres:5432/uload
|
||||
```
|
||||
|
||||
**Wichtig:** Kopiere diese URL - du brauchst sie später!
|
||||
|
||||
---
|
||||
|
||||
## Schritt 4: Redis erstellen (optional, aber empfohlen)
|
||||
|
||||
### 4.1 In Coolify
|
||||
|
||||
1. Klicke **+ New Resource**
|
||||
2. Wähle **Database**
|
||||
3. Wähle **Redis**
|
||||
4. Konfiguriere:
|
||||
- **Name:** `uload-redis`
|
||||
- **Version:** `7-alpine`
|
||||
5. Klicke **Start**
|
||||
|
||||
### 4.2 Connection String notieren
|
||||
|
||||
```
|
||||
redis://uload-redis:6379
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 5: GitHub Repository verbinden
|
||||
|
||||
### 5.1 GitHub App erstellen
|
||||
|
||||
1. In Coolify: Gehe zu **Sources** (linke Sidebar)
|
||||
2. Klicke **+ Add**
|
||||
3. Wähle **GitHub App**
|
||||
4. Klicke **Register GitHub App**
|
||||
5. Du wirst zu GitHub weitergeleitet
|
||||
6. Gib der App einen Namen (z.B. "coolify-uload")
|
||||
7. Klicke **Create GitHub App**
|
||||
8. Installiere die App für dein Repository
|
||||
|
||||
### 5.2 Repository-Zugriff gewähren
|
||||
|
||||
1. Wähle **Only select repositories**
|
||||
2. Wähle `manacore-monorepo`
|
||||
3. Klicke **Install**
|
||||
|
||||
---
|
||||
|
||||
## Schritt 6: uload Application erstellen
|
||||
|
||||
### 6.1 Neue Application
|
||||
|
||||
1. Klicke **+ New Resource**
|
||||
2. Wähle **Application**
|
||||
3. Wähle deine **GitHub App** als Source
|
||||
4. Wähle das Repository `manacore-monorepo`
|
||||
5. Wähle Branch: `main`
|
||||
|
||||
### 6.2 Build-Konfiguration (WICHTIG!)
|
||||
|
||||
Da uload Teil eines Monorepos ist, muss die Build-Konfiguration genau so sein:
|
||||
|
||||
| Einstellung | Wert |
|
||||
|-------------|------|
|
||||
| **Base Directory** | `/` (leer lassen oder `/`) |
|
||||
| **Build Pack** | Dockerfile |
|
||||
| **Dockerfile Location** | `uload/Dockerfile` |
|
||||
| **Port Exposes** | `3000` |
|
||||
|
||||
**Warum `/` als Base Directory?**
|
||||
Das Dockerfile benötigt Zugriff auf:
|
||||
- `uload/apps/web/` (die App)
|
||||
- `packages/shared-*` (gemeinsame Packages)
|
||||
- `pnpm-workspace.yaml` und `pnpm-lock.yaml` (Workspace-Config)
|
||||
|
||||
---
|
||||
|
||||
## Schritt 7: Environment Variables setzen
|
||||
|
||||
### 7.1 In Coolify
|
||||
|
||||
Gehe zu deiner Application → **Environment Variables** → **Add Variable**
|
||||
|
||||
### 7.2 Erforderliche Variablen
|
||||
|
||||
```env
|
||||
# === APP ===
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
ORIGIN=https://ulo.ad
|
||||
|
||||
# === DATABASE ===
|
||||
# Von Schritt 3.2 - PostgreSQL Internal URL
|
||||
DATABASE_URL=postgresql://uload:DEIN-PASSWORT@uload-postgres:5432/uload
|
||||
|
||||
# === REDIS (optional) ===
|
||||
# Von Schritt 4.2
|
||||
REDIS_URL=redis://uload-redis:6379
|
||||
|
||||
# === AUTH ===
|
||||
# Generiere mit: openssl rand -base64 32
|
||||
AUTH_SECRET=GENERIERE-EINEN-SICHEREN-STRING-HIER
|
||||
|
||||
# === EMAIL (Resend) ===
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# === PAYMENTS (Stripe) ===
|
||||
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# === STORAGE (Cloudflare R2) ===
|
||||
R2_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
R2_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
R2_BUCKET_NAME=uload-uploads
|
||||
R2_ENDPOINT=https://xxxxxxxxxx.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
### 7.3 AUTH_SECRET generieren
|
||||
|
||||
Auf deinem lokalen Rechner:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
Kopiere das Ergebnis als `AUTH_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 8: Domain konfigurieren
|
||||
|
||||
### 8.1 DNS-Einträge setzen
|
||||
|
||||
Bei deinem DNS-Provider (z.B. Cloudflare, Namecheap):
|
||||
|
||||
| Type | Name | Value | TTL |
|
||||
|------|------|-------|-----|
|
||||
| A | @ | DEINE-SERVER-IP | 3600 |
|
||||
| A | www | DEINE-SERVER-IP | 3600 |
|
||||
|
||||
### 8.2 Domain in Coolify hinzufügen
|
||||
|
||||
1. Gehe zu deiner Application → **Settings**
|
||||
2. Unter **Domains** klicke **+ Add**
|
||||
3. Gib ein: `ulo.ad`
|
||||
4. Aktiviere: **Generate SSL Certificate** (Let's Encrypt)
|
||||
5. Optional: Füge auch `www.ulo.ad` hinzu mit Redirect
|
||||
|
||||
### 8.3 Warten
|
||||
|
||||
DNS-Änderungen können 5-30 Minuten dauern. SSL-Zertifikate werden automatisch erstellt.
|
||||
|
||||
---
|
||||
|
||||
## Schritt 9: Deployment starten
|
||||
|
||||
### 9.1 Erster Deploy
|
||||
|
||||
1. Gehe zu deiner Application
|
||||
2. Klicke **Deploy**
|
||||
3. Warte auf den Build (ca. 3-5 Minuten)
|
||||
|
||||
### 9.2 Build-Logs überwachen
|
||||
|
||||
Klicke auf das laufende Deployment um die Logs zu sehen.
|
||||
|
||||
**Erfolgreicher Build zeigt:**
|
||||
```
|
||||
✔ done
|
||||
Listening on http://0.0.0.0:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 10: Datenbank-Migration
|
||||
|
||||
### 10.1 Nach erstem Deployment
|
||||
|
||||
Die Datenbank-Tabellen müssen erstellt werden:
|
||||
|
||||
1. In Coolify: Gehe zu deiner Application → **Terminal**
|
||||
2. Oder via SSH:
|
||||
|
||||
```bash
|
||||
# Container-Name finden
|
||||
docker ps | grep uload
|
||||
|
||||
# In Container gehen
|
||||
docker exec -it CONTAINER-NAME sh
|
||||
|
||||
# Migration ausführen
|
||||
npx drizzle-kit push
|
||||
```
|
||||
|
||||
### 10.2 Alternative: Pre-Deploy Command
|
||||
|
||||
In Coolify → Application → **Settings** → **Pre-Deploy Command**:
|
||||
|
||||
```bash
|
||||
cd /app && npx drizzle-kit push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schritt 11: Verifizieren
|
||||
|
||||
### 11.1 Health Check
|
||||
|
||||
```bash
|
||||
curl https://ulo.ad/api/health
|
||||
```
|
||||
|
||||
Erwartete Antwort:
|
||||
```json
|
||||
{"status":"ok","timestamp":"2025-11-25T12:00:00.000Z","uptime":123.45}
|
||||
```
|
||||
|
||||
### 11.2 Website öffnen
|
||||
|
||||
Öffne `https://ulo.ad` im Browser.
|
||||
|
||||
---
|
||||
|
||||
## Automatische Deployments
|
||||
|
||||
### Webhook (Standard)
|
||||
|
||||
Coolify erstellt automatisch einen GitHub Webhook. Bei jedem Push auf `main` wird automatisch deployed.
|
||||
|
||||
### Manuelles Deployment
|
||||
|
||||
In Coolify: Application → **Redeploy**
|
||||
|
||||
---
|
||||
|
||||
## Wartung & Monitoring
|
||||
|
||||
### Logs anzeigen
|
||||
|
||||
**In Coolify:**
|
||||
Application → **Logs**
|
||||
|
||||
**Via SSH:**
|
||||
```bash
|
||||
docker logs -f $(docker ps -qf "name=uload")
|
||||
```
|
||||
|
||||
### Container neustarten
|
||||
|
||||
In Coolify: Application → **Restart**
|
||||
|
||||
### Datenbank Backup
|
||||
|
||||
```bash
|
||||
# Manuelles Backup
|
||||
docker exec uload-postgres pg_dump -U uload uload > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# Backup wiederherstellen
|
||||
cat backup_20251125.sql | docker exec -i uload-postgres psql -U uload uload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build schlägt fehl
|
||||
|
||||
| Problem | Lösung |
|
||||
|---------|--------|
|
||||
| "Cannot find package" | Prüfe Base Directory (muss `/` sein) |
|
||||
| "pnpm-lock.yaml not found" | Prüfe dass pnpm-lock.yaml im Repo ist |
|
||||
| Timeout beim Build | Erhöhe Build-Timeout in Coolify Settings |
|
||||
|
||||
### Container startet nicht
|
||||
|
||||
| Problem | Lösung |
|
||||
|---------|--------|
|
||||
| "Missing API key" | Prüfe RESEND_API_KEY Environment Variable |
|
||||
| "Cannot connect to database" | Prüfe DATABASE_URL (Internal URL!) |
|
||||
| Port already in use | Prüfe ob alter Container noch läuft |
|
||||
|
||||
### SSL-Zertifikat Fehler
|
||||
|
||||
1. Prüfe DNS-Einträge (A-Record auf Server-IP)
|
||||
2. Warte 5-10 Minuten
|
||||
3. In Coolify: Domain löschen und neu hinzufügen
|
||||
4. Prüfe ob Port 80 erreichbar ist (Firewall)
|
||||
|
||||
### Datenbank-Verbindung fehlgeschlagen
|
||||
|
||||
1. Prüfe ob PostgreSQL-Container läuft
|
||||
2. Verwende **Internal URL** (nicht External!)
|
||||
3. Teste Verbindung:
|
||||
```bash
|
||||
docker exec -it uload-postgres psql -U uload -d uload -c "SELECT 1"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkliste Production-Ready
|
||||
|
||||
- [ ] Hetzner VPS erstellt und SSH funktioniert
|
||||
- [ ] Coolify installiert und Admin-Account erstellt
|
||||
- [ ] PostgreSQL läuft und CONNECTION_STRING notiert
|
||||
- [ ] Redis läuft (optional)
|
||||
- [ ] GitHub Repository verbunden
|
||||
- [ ] Application mit korrektem Dockerfile-Pfad erstellt
|
||||
- [ ] Alle Environment Variables gesetzt
|
||||
- [ ] AUTH_SECRET generiert (min. 32 Zeichen)
|
||||
- [ ] DNS A-Records konfiguriert
|
||||
- [ ] Domain in Coolify hinzugefügt
|
||||
- [ ] SSL-Zertifikat aktiv
|
||||
- [ ] Erster Deploy erfolgreich
|
||||
- [ ] Datenbank-Migration ausgeführt
|
||||
- [ ] Health-Check funktioniert (`/api/health`)
|
||||
- [ ] Website erreichbar
|
||||
|
||||
---
|
||||
|
||||
## Dateien im Repository
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `uload/Dockerfile` | Multi-Stage Docker Build |
|
||||
| `uload/docker-compose.yml` | Lokale Entwicklung |
|
||||
| `uload/docker-compose.coolify.yml` | Coolify Deployment |
|
||||
| `uload/docker-compose.prod.yml` | Standalone Production |
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
Bei Problemen:
|
||||
1. Coolify Logs prüfen
|
||||
2. Container Logs prüfen (`docker logs`)
|
||||
3. GitHub Issues: https://github.com/anthropics/claude-code/issues
|
||||
4
maerchenzauber/apps/landing/src/pages/de/index.astro
Normal file
4
maerchenzauber/apps/landing/src/pages/de/index.astro
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
// Redirect /de to / - the site is already in German
|
||||
return Astro.redirect('/', 301);
|
||||
---
|
||||
|
|
@ -51,6 +51,18 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
|
|||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
uload: {
|
||||
id: 'uload',
|
||||
name: 'uLoad',
|
||||
tagline: 'Smart URL Shortener',
|
||||
primaryColor: '#3b82f6',
|
||||
secondaryColor: '#60a5fa',
|
||||
// Link/Chain icon
|
||||
logoPath: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1',
|
||||
logoViewBox: '0 0 24 24',
|
||||
logoStroke: true,
|
||||
logoStrokeWidth: 2,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export {
|
|||
MemoroLogo,
|
||||
ManaCoreLogo,
|
||||
ManaDeckLogo,
|
||||
StorytellerLogo
|
||||
StorytellerLogo,
|
||||
UloadLogo
|
||||
} from './logos';
|
||||
|
||||
// Configuration
|
||||
|
|
|
|||
13
packages/shared-branding/src/logos/UloadLogo.svelte
Normal file
13
packages/shared-branding/src/logos/UloadLogo.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import AppLogo from '../AppLogo.svelte';
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 55, color, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AppLogo app="uload" {size} {color} class={className} />
|
||||
|
|
@ -6,3 +6,4 @@ export { default as MemoroLogo } from './MemoroLogo.svelte';
|
|||
export { default as ManaCoreLogo } from './ManaCoreLogo.svelte';
|
||||
export { default as ManaDeckLogo } from './ManaDeckLogo.svelte';
|
||||
export { default as StorytellerLogo } from './StorytellerLogo.svelte';
|
||||
export { default as UloadLogo } from './UloadLogo.svelte';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* App identifiers for branding
|
||||
*/
|
||||
export type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber';
|
||||
export type AppId = 'memoro' | 'manacore' | 'manadeck' | 'maerchenzauber' | 'uload';
|
||||
|
||||
/**
|
||||
* App branding configuration
|
||||
|
|
|
|||
4
packages/uload-database/.env.example
Normal file
4
packages/uload-database/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Database connection string
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload
|
||||
# Or use project-specific variable
|
||||
ULOAD_DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload
|
||||
36
packages/uload-database/docker-compose.yml
Normal file
36
packages/uload-database/docker-compose.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: uload-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5434:5432'
|
||||
environment:
|
||||
POSTGRES_DB: uload
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- uload_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: uload-pgadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5051:80'
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@uload.local
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
volumes:
|
||||
- uload_pgadmin_data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
uload_postgres_data:
|
||||
uload_pgadmin_data:
|
||||
12
packages/uload-database/drizzle.config.ts
Normal file
12
packages/uload-database/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema/index.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || process.env.ULOAD_DATABASE_URL || '',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
54
packages/uload-database/package.json
Normal file
54
packages/uload-database/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "@manacore/uload-database",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"import": "./dist/schema/index.js",
|
||||
"require": "./dist/schema/index.js",
|
||||
"default": "./dist/schema/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client.d.ts",
|
||||
"import": "./dist/client.js",
|
||||
"require": "./dist/client.js",
|
||||
"default": "./dist/client.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"prepare": "pnpm build",
|
||||
"docker:up": "docker compose up -d",
|
||||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs -f postgres",
|
||||
"db:generate": "dotenv -- drizzle-kit generate",
|
||||
"db:migrate": "dotenv -- drizzle-kit migrate",
|
||||
"db:push": "dotenv -- drizzle-kit push --force",
|
||||
"db:studio": "dotenv -- drizzle-kit studio",
|
||||
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
|
||||
"db:test": "dotenv -- tsx src/test-connection.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^7.4.0",
|
||||
"drizzle-kit": "^0.28.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.3",
|
||||
"@types/node": "^22.10.0"
|
||||
}
|
||||
}
|
||||
97
packages/uload-database/src/client.ts
Normal file
97
packages/uload-database/src/client.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema/index.js';
|
||||
|
||||
// Singleton instance for the database client
|
||||
let dbInstance: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
let pgClient: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
/**
|
||||
* Get the database URL from environment variables
|
||||
*/
|
||||
function getDatabaseUrl(): string {
|
||||
const url = process.env.DATABASE_URL || process.env.ULOAD_DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'Database URL not found. Set DATABASE_URL or ULOAD_DATABASE_URL environment variable.'
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database client
|
||||
* Uses connection pooling with sensible defaults for serverless environments
|
||||
*/
|
||||
export function createClient(connectionString?: string) {
|
||||
const url = connectionString || getDatabaseUrl();
|
||||
|
||||
const client = postgres(url, {
|
||||
max: 10, // Maximum connections in the pool
|
||||
idle_timeout: 20, // Close idle connections after 20 seconds
|
||||
connect_timeout: 10, // Connection timeout in seconds
|
||||
prepare: false, // Disable prepared statements for serverless
|
||||
});
|
||||
|
||||
return drizzle(client, { schema });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton database instance
|
||||
* Creates a new instance if one doesn't exist
|
||||
*/
|
||||
export function getDb() {
|
||||
if (!dbInstance) {
|
||||
const url = getDatabaseUrl();
|
||||
pgClient = postgres(url, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
prepare: false,
|
||||
});
|
||||
dbInstance = drizzle(pgClient, { schema });
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
* Should be called when shutting down the application
|
||||
*/
|
||||
export async function closeDb() {
|
||||
if (pgClient) {
|
||||
await pgClient.end();
|
||||
pgClient = null;
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export the database type for typing purposes
|
||||
export type Database = ReturnType<typeof createClient>;
|
||||
|
||||
// Re-export commonly used Drizzle utilities
|
||||
export {
|
||||
eq,
|
||||
ne,
|
||||
gt,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
and,
|
||||
or,
|
||||
not,
|
||||
inArray,
|
||||
notInArray,
|
||||
isNull,
|
||||
isNotNull,
|
||||
like,
|
||||
ilike,
|
||||
sql,
|
||||
asc,
|
||||
desc,
|
||||
count,
|
||||
sum,
|
||||
avg,
|
||||
min,
|
||||
max,
|
||||
} from 'drizzle-orm';
|
||||
32
packages/uload-database/src/index.ts
Normal file
32
packages/uload-database/src/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Database client exports
|
||||
export { createClient, getDb, closeDb, type Database } from './client.js';
|
||||
|
||||
// Re-export Drizzle utilities
|
||||
export {
|
||||
eq,
|
||||
ne,
|
||||
gt,
|
||||
gte,
|
||||
lt,
|
||||
lte,
|
||||
and,
|
||||
or,
|
||||
not,
|
||||
inArray,
|
||||
notInArray,
|
||||
isNull,
|
||||
isNotNull,
|
||||
like,
|
||||
ilike,
|
||||
sql,
|
||||
asc,
|
||||
desc,
|
||||
count,
|
||||
sum,
|
||||
avg,
|
||||
min,
|
||||
max,
|
||||
} from './client.js';
|
||||
|
||||
// Schema exports
|
||||
export * from './schema/index.js';
|
||||
32
packages/uload-database/src/schema/accounts.ts
Normal file
32
packages/uload-database/src/schema/accounts.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
boolean,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
|
||||
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) => [index('accounts_owner_idx').on(table.owner)]
|
||||
);
|
||||
|
||||
export type Account = typeof accounts.$inferSelect;
|
||||
export type NewAccount = typeof accounts.$inferInsert;
|
||||
33
packages/uload-database/src/schema/clicks.ts
Normal file
33
packages/uload-database/src/schema/clicks.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { links } from './links.js';
|
||||
|
||||
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) => [
|
||||
index('clicks_link_id_idx').on(table.linkId),
|
||||
index('clicks_clicked_at_idx').on(table.clickedAt),
|
||||
index('clicks_country_idx').on(table.country),
|
||||
]
|
||||
);
|
||||
|
||||
export type Click = typeof clicks.$inferSelect;
|
||||
export type NewClick = typeof clicks.$inferInsert;
|
||||
18
packages/uload-database/src/schema/index.ts
Normal file
18
packages/uload-database/src/schema/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Tables
|
||||
export { users, type User, type NewUser } from './users.js';
|
||||
export { accounts, type Account, type NewAccount } from './accounts.js';
|
||||
export { workspaces, type Workspace, type NewWorkspace } from './workspaces.js';
|
||||
export { links, type Link, type NewLink } from './links.js';
|
||||
export { clicks, type Click, type NewClick } from './clicks.js';
|
||||
export { tags, linkTags, type Tag, type NewTag, type LinkTag, type NewLinkTag } from './tags.js';
|
||||
|
||||
// Relations
|
||||
export {
|
||||
usersRelations,
|
||||
linksRelations,
|
||||
clicksRelations,
|
||||
tagsRelations,
|
||||
linkTagsRelations,
|
||||
accountsRelations,
|
||||
workspacesRelations,
|
||||
} from './relations.js';
|
||||
50
packages/uload-database/src/schema/links.ts
Normal file
50
packages/uload-database/src/schema/links.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
import { accounts } from './accounts.js';
|
||||
import { workspaces } from './workspaces.js';
|
||||
|
||||
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'),
|
||||
tags: jsonb('tags').$type<string[]>(),
|
||||
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) => [
|
||||
index('links_user_id_idx').on(table.userId),
|
||||
index('links_short_code_idx').on(table.shortCode),
|
||||
index('links_workspace_id_idx').on(table.workspaceId),
|
||||
index('links_account_owner_idx').on(table.accountOwner),
|
||||
index('links_is_active_idx').on(table.isActive),
|
||||
]
|
||||
);
|
||||
|
||||
export type Link = typeof links.$inferSelect;
|
||||
export type NewLink = typeof links.$inferInsert;
|
||||
52
packages/uload-database/src/schema/relations.ts
Normal file
52
packages/uload-database/src/schema/relations.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { relations } from 'drizzle-orm';
|
||||
import { users } from './users.js';
|
||||
import { links } from './links.js';
|
||||
import { clicks } from './clicks.js';
|
||||
import { tags, linkTags } from './tags.js';
|
||||
import { accounts } from './accounts.js';
|
||||
import { workspaces } from './workspaces.js';
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
links: many(links),
|
||||
tags: many(tags),
|
||||
ownedAccounts: many(accounts),
|
||||
ownedWorkspaces: many(workspaces),
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
56
packages/uload-database/src/schema/tags.ts
Normal file
56
packages/uload-database/src/schema/tags.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
import { links } from './links.js';
|
||||
|
||||
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) => [
|
||||
index('tags_user_id_idx').on(table.userId),
|
||||
index('tags_slug_idx').on(table.slug),
|
||||
]
|
||||
);
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
|
||||
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) => [
|
||||
index('link_tags_link_id_idx').on(table.linkId),
|
||||
index('link_tags_tag_id_idx').on(table.tagId),
|
||||
index('link_tags_unique_idx').on(table.linkId, table.tagId),
|
||||
]
|
||||
);
|
||||
|
||||
export type LinkTag = typeof linkTags.$inferSelect;
|
||||
export type NewLinkTag = typeof linkTags.$inferInsert;
|
||||
47
packages/uload-database/src/schema/users.ts
Normal file
47
packages/uload-database/src/schema/users.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
timestamp,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
externalAuthId: text('external_auth_id').unique(), // For Mana Core auth
|
||||
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) => [
|
||||
index('users_email_idx').on(table.email),
|
||||
index('users_username_idx').on(table.username),
|
||||
index('users_external_auth_id_idx').on(table.externalAuthId),
|
||||
]
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
// Relations will be defined in relations.ts to avoid circular imports
|
||||
24
packages/uload-database/src/schema/workspaces.ts
Normal file
24
packages/uload-database/src/schema/workspaces.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users.js';
|
||||
|
||||
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) => [
|
||||
index('workspaces_slug_idx').on(table.slug),
|
||||
index('workspaces_owner_idx').on(table.owner),
|
||||
]
|
||||
);
|
||||
|
||||
export type Workspace = typeof workspaces.$inferSelect;
|
||||
export type NewWorkspace = typeof workspaces.$inferInsert;
|
||||
21
packages/uload-database/tsconfig.json
Normal file
21
packages/uload-database/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: "Picture vs Traditional Design"
|
||||
order: 1
|
||||
---
|
||||
|
||||
Compare Picture's AI-powered approach with traditional design workflows and see how you can save time and resources.
|
||||
6
picture/apps/landing/src/content/faq/en/placeholder.md
Normal file
6
picture/apps/landing/src/content/faq/en/placeholder.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: "What is Picture?"
|
||||
order: 1
|
||||
---
|
||||
|
||||
Picture is an AI-powered image generation platform that helps you create stunning visuals from text descriptions.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: "Marketing & Social Media"
|
||||
icon: "📱"
|
||||
order: 1
|
||||
---
|
||||
|
||||
Create eye-catching visuals for your social media campaigns and marketing materials with AI-generated images.
|
||||
606
pnpm-lock.yaml
generated
606
pnpm-lock.yaml
generated
|
|
@ -312,7 +312,7 @@ importers:
|
|||
version: 17.0.7(expo@54.0.25)(react@19.1.0)
|
||||
expo-router:
|
||||
specifier: ~6.0.14
|
||||
version: 6.0.15(jqlydxfw6sdg7rjjcbafgjr6wy)
|
||||
version: 6.0.15(wy3aqjqih33pc4tbjsiq2nsp7q)
|
||||
expo-secure-store:
|
||||
specifier: ~15.0.7
|
||||
version: 15.0.7(expo@54.0.25)
|
||||
|
|
@ -415,7 +415,7 @@ importers:
|
|||
version: 7.28.5
|
||||
'@testing-library/react-native':
|
||||
specifier: ^13.3.3
|
||||
version: 13.3.3(jest@29.5.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
version: 13.3.3(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@types/jest':
|
||||
specifier: ^29.5.12
|
||||
version: 29.5.14
|
||||
|
|
@ -433,10 +433,10 @@ importers:
|
|||
version: 10.0.0
|
||||
jest:
|
||||
specifier: ^29.2.1
|
||||
version: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
|
||||
version: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-expo:
|
||||
specifier: ~54.0.13
|
||||
version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
|
||||
version: 54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
|
||||
patch-package:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.1
|
||||
|
|
@ -1861,6 +1861,31 @@ importers:
|
|||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/uload-database:
|
||||
dependencies:
|
||||
drizzle-orm:
|
||||
specifier: ^0.36.0
|
||||
version: 0.36.4(@types/react@19.2.7)(kysely@0.27.6)(postgres@3.4.7)(react@19.1.0)
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.7
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.0
|
||||
version: 22.19.1
|
||||
dotenv-cli:
|
||||
specifier: ^7.4.0
|
||||
version: 7.4.4
|
||||
drizzle-kit:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.1
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.20.6
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
picture:
|
||||
dependencies:
|
||||
expo:
|
||||
|
|
@ -2267,6 +2292,143 @@ importers:
|
|||
specifier: ~5.8.3
|
||||
version: 5.8.3
|
||||
|
||||
uload/apps/backend:
|
||||
dependencies:
|
||||
'@mana-core/nestjs-integration':
|
||||
specifier: git+https://github.com/Memo-2023/mana-core-nestjs-package.git
|
||||
version: git+https://git@github.com:Memo-2023/mana-core-nestjs-package.git#eca58cc09d6e55d4f9d34e719fa0e0ed8d0101c7(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/config@4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2))(@nestjs/core@11.1.9)(axios@1.13.2)(rxjs@7.8.2)
|
||||
'@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.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2)
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
|
||||
'@nestjs/terminus':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0(@grpc/grpc-js@1.14.1)(@grpc/proto-loader@0.8.0)(@nestjs/axios@4.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.2)(rxjs@7.8.2))(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
axios:
|
||||
specifier: ^1.7.2
|
||||
version: 1.13.2
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
class-validator:
|
||||
specifier: ^0.14.2
|
||||
version: 0.14.2
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.8.2
|
||||
joi:
|
||||
specifier: ^18.0.1
|
||||
version: 18.0.2
|
||||
nanoid:
|
||||
specifier: ^5.0.7
|
||||
version: 5.1.6
|
||||
nestjs-cls:
|
||||
specifier: ^6.0.1
|
||||
version: 6.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(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.6
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.12(@types/node@22.19.1)(esbuild@0.27.0)
|
||||
'@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.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.5
|
||||
'@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.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(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.1.4
|
||||
ts-jest:
|
||||
specifier: ^29.2.5
|
||||
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(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.100.2(esbuild@0.27.0))
|
||||
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
|
||||
|
||||
uload/apps/landing:
|
||||
dependencies:
|
||||
'@astrojs/check':
|
||||
specifier: ^0.9.4
|
||||
version: 0.9.5(prettier-plugin-astro@0.14.1)(prettier@3.6.2)(typescript@5.9.3)
|
||||
'@astrojs/mdx':
|
||||
specifier: ^4.0.8
|
||||
version: 4.3.12(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))
|
||||
'@astrojs/sitemap':
|
||||
specifier: ^3.2.1
|
||||
version: 3.6.0
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
astro:
|
||||
specifier: ^5.1.1
|
||||
version: 5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
version: 22.19.1
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
uload/apps/web:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
|
|
@ -2275,6 +2437,12 @@ importers:
|
|||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.934.0
|
||||
version: 3.939.0
|
||||
'@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(kysely@0.27.6)(postgres@3.4.7)
|
||||
|
|
@ -6902,6 +7070,9 @@ 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==}
|
||||
|
||||
|
|
@ -8574,6 +8745,9 @@ 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'}
|
||||
|
|
@ -10910,6 +11084,9 @@ 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'}
|
||||
|
|
@ -12374,6 +12551,11 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.6:
|
||||
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
|
@ -14962,6 +15144,9 @@ 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
|
||||
|
|
@ -14970,6 +15155,10 @@ packages:
|
|||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@2.0.6:
|
||||
resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@1.0.6:
|
||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||
|
||||
|
|
@ -16112,6 +16301,25 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@astrojs/markdown-remark': 6.3.9
|
||||
'@mdx-js/mdx': 3.1.1
|
||||
acorn: 8.15.0
|
||||
astro: 5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
es-module-lexer: 1.7.0
|
||||
estree-util-visit: 2.0.0
|
||||
hast-util-to-html: 9.0.5
|
||||
piccolore: 0.1.3
|
||||
rehype-raw: 7.0.0
|
||||
remark-gfm: 4.0.1
|
||||
remark-smartypants: 3.0.2
|
||||
source-map: 0.7.6
|
||||
unist-util-visit: 5.0.0
|
||||
vfile: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@astrojs/mdx@4.3.12(astro@5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@astrojs/markdown-remark': 6.3.9
|
||||
|
|
@ -16209,6 +16417,16 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
astro: 5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(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@22.19.1)(typescript@5.9.3))
|
||||
tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1))(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
astro: 5.16.0(@types/node@24.10.1)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1)
|
||||
|
|
@ -19252,6 +19470,41 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@jest/console': 29.7.0
|
||||
'@jest/reporters': 29.7.0
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 22.19.1
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
exit: 0.1.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-changed-files: 29.7.0
|
||||
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-haste-map: 29.7.0
|
||||
jest-message-util: 29.7.0
|
||||
jest-regex-util: 29.6.3
|
||||
jest-resolve: 29.7.0
|
||||
jest-resolve-dependencies: 29.7.0
|
||||
jest-runner: 29.7.0
|
||||
jest-runtime: 29.7.0
|
||||
jest-snapshot: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
jest-watcher: 29.7.0
|
||||
micromatch: 4.0.8
|
||||
pretty-format: 29.7.0
|
||||
slash: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@jest/console': 30.2.0
|
||||
|
|
@ -21690,7 +21943,7 @@ snapshots:
|
|||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/react-native@13.3.3(jest@29.5.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(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@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
jest-matcher-utils: 30.2.0
|
||||
picocolors: 1.1.1
|
||||
|
|
@ -21700,7 +21953,7 @@ snapshots:
|
|||
react-test-renderer: 19.1.0(react@19.1.0)
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
jest: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
|
||||
jest: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
|
||||
'@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
|
|
@ -21712,7 +21965,7 @@ snapshots:
|
|||
react-test-renderer: 19.1.0(react@19.1.0)
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))
|
||||
optional: true
|
||||
|
||||
'@testing-library/react-native@13.3.3(jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
|
|
@ -21725,7 +21978,7 @@ snapshots:
|
|||
react-test-renderer: 19.1.0(react@19.1.0)
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
jest: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))
|
||||
optional: true
|
||||
|
||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
||||
|
|
@ -22055,6 +22308,8 @@ snapshots:
|
|||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/ua-parser-js@0.7.39': {}
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
|
@ -23266,6 +23521,108 @@ snapshots:
|
|||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.16.0(@types/node@22.19.1)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(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.0.2
|
||||
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(ioredis@5.8.2)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
|
||||
vitefu: 1.1.1(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(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(@types/node@24.10.1)(ioredis@5.8.2)(jiti@1.21.7)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.0
|
||||
|
|
@ -24325,6 +24682,21 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
create-jest@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
chalk: 4.1.2
|
||||
exit: 0.1.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-util: 29.7.0
|
||||
prompts: 2.4.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
|
|
@ -24527,6 +24899,8 @@ snapshots:
|
|||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
detect-europe-js@0.1.2: {}
|
||||
|
||||
detect-libc@1.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
|
@ -26451,52 +26825,6 @@ snapshots:
|
|||
- '@types/react-dom'
|
||||
- supports-color
|
||||
|
||||
expo-router@6.0.15(jqlydxfw6sdg7rjjcbafgjr6wy):
|
||||
dependencies:
|
||||
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@expo/schema-utils': 0.1.7
|
||||
'@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0)
|
||||
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/native-stack': 7.7.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
client-only: 0.0.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
|
||||
expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
expo-server: 1.0.4
|
||||
fast-deep-equal: 3.1.3
|
||||
invariant: 2.2.4
|
||||
nanoid: 3.3.11
|
||||
query-string: 7.1.3
|
||||
react: 19.1.0
|
||||
react-fast-compare: 3.2.2
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
|
||||
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
semver: 7.6.3
|
||||
server-only: 0.0.1
|
||||
sf-symbols-typescript: 2.1.0
|
||||
shallowequal: 1.1.0
|
||||
use-latest-callback: 0.2.6(react@19.1.0)
|
||||
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.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.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@testing-library/react-native': 13.3.3(jest@29.5.0)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.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.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-masked-view/masked-view'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- supports-color
|
||||
|
||||
expo-router@6.0.15(qjp3usx4acoq47dkosl6pmu254):
|
||||
dependencies:
|
||||
'@expo/metro-runtime': 6.1.2(expo@54.0.13)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -26589,6 +26917,52 @@ snapshots:
|
|||
- '@types/react-dom'
|
||||
- supports-color
|
||||
|
||||
expo-router@6.0.15(wy3aqjqih33pc4tbjsiq2nsp7q):
|
||||
dependencies:
|
||||
'@expo/metro-runtime': 6.1.2(expo@54.0.25)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@expo/schema-utils': 0.1.7
|
||||
'@radix-ui/react-slot': 1.2.0(@types/react@19.2.7)(react@19.1.0)
|
||||
'@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/native': 7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@react-navigation/native-stack': 7.7.0(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
client-only: 0.0.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
expo: 54.0.25(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
expo-constants: 18.0.10(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
|
||||
expo-linking: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
expo-server: 1.0.4
|
||||
fast-deep-equal: 3.1.3
|
||||
invariant: 2.2.4
|
||||
nanoid: 3.3.11
|
||||
query-string: 7.1.3
|
||||
react: 19.1.0
|
||||
react-fast-compare: 3.2.2
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
|
||||
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-safe-area-context: 5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-screens: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
semver: 7.6.3
|
||||
server-only: 0.0.1
|
||||
sf-symbols-typescript: 2.1.0
|
||||
shallowequal: 1.1.0
|
||||
use-latest-callback: 0.2.6(react@19.1.0)
|
||||
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.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.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
'@testing-library/react-native': 13.3.3(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react-test-renderer@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.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.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-server-dom-webpack: 19.0.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(webpack@5.100.2)
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-masked-view/masked-view'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- supports-color
|
||||
|
||||
expo-secure-store@15.0.7(expo@54.0.13):
|
||||
dependencies:
|
||||
expo: 54.0.13(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
|
||||
|
|
@ -28177,6 +28551,8 @@ snapshots:
|
|||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-standalone-pwa@0.1.1: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-string@1.1.1:
|
||||
|
|
@ -28384,6 +28760,25 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-cli@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
chalk: 4.1.2
|
||||
create-jest: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
exit: 0.1.2
|
||||
import-local: 3.2.0
|
||||
jest-config: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-cli@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
|
|
@ -28403,6 +28798,26 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-cli@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
'@jest/test-result': 30.2.0
|
||||
'@jest/types': 30.2.0
|
||||
chalk: 4.1.2
|
||||
exit-x: 0.2.2
|
||||
import-local: 3.2.0
|
||||
jest-config: 30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-util: 30.2.0
|
||||
jest-validate: 30.2.0
|
||||
yargs: 17.7.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
optional: true
|
||||
|
||||
jest-config@29.7.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
|
@ -28465,6 +28880,37 @@ snapshots:
|
|||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-config@29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@jest/test-sequencer': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.28.5)
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
deepmerge: 4.3.1
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
jest-circus: 29.7.0
|
||||
jest-environment-node: 29.7.0
|
||||
jest-get-type: 29.6.3
|
||||
jest-regex-util: 29.6.3
|
||||
jest-resolve: 29.7.0
|
||||
jest-runner: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
micromatch: 4.0.8
|
||||
parse-json: 5.2.0
|
||||
pretty-format: 29.7.0
|
||||
slash: 3.0.0
|
||||
strip-json-comments: 3.1.1
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.1
|
||||
ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-config@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
|
@ -28571,7 +29017,7 @@ snapshots:
|
|||
jest-util: 30.2.0
|
||||
jest-validate: 30.2.0
|
||||
|
||||
jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2):
|
||||
jest-expo@54.0.13(@babel/core@7.28.5)(expo@54.0.25)(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(webpack@5.100.2):
|
||||
dependencies:
|
||||
'@expo/config': 12.0.10
|
||||
'@expo/json-file': 10.0.7
|
||||
|
|
@ -28582,7 +29028,7 @@ snapshots:
|
|||
jest-environment-jsdom: 29.7.0
|
||||
jest-snapshot: 29.7.0
|
||||
jest-watch-select-projects: 2.0.0
|
||||
jest-watch-typeahead: 2.2.1(jest@29.5.0)
|
||||
jest-watch-typeahead: 2.2.1(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))
|
||||
json5: 2.2.3
|
||||
lodash: 4.17.21
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
|
||||
|
|
@ -28943,11 +29389,11 @@ snapshots:
|
|||
chalk: 3.0.0
|
||||
prompts: 2.4.2
|
||||
|
||||
jest-watch-typeahead@2.2.1(jest@29.5.0):
|
||||
jest-watch-typeahead@2.2.1(jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))):
|
||||
dependencies:
|
||||
ansi-escapes: 6.2.1
|
||||
chalk: 4.1.2
|
||||
jest: 29.5.0(@types/node@18.15.11)(ts-node@10.9.2(@types/node@18.15.11)(typescript@5.9.3))
|
||||
jest: 29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
jest-regex-util: 29.6.3
|
||||
jest-watcher: 29.7.0
|
||||
slash: 5.1.0
|
||||
|
|
@ -29009,6 +29455,18 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest@29.5.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
'@jest/types': 29.6.3
|
||||
import-local: 3.2.0
|
||||
jest-cli: 29.7.0(@types/node@22.19.1)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest@30.2.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
|
|
@ -29022,6 +29480,20 @@ snapshots:
|
|||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest@30.2.0(esbuild-register@3.6.0(esbuild@0.27.0)):
|
||||
dependencies:
|
||||
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3))
|
||||
'@jest/types': 30.2.0
|
||||
import-local: 3.2.0
|
||||
jest-cli: 30.2.0(esbuild-register@3.6.0(esbuild@0.27.0))
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- esbuild-register
|
||||
- supports-color
|
||||
- ts-node
|
||||
optional: true
|
||||
|
||||
jimp-compact@0.16.1: {}
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
|
@ -30690,6 +31162,8 @@ snapshots:
|
|||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
nativewind@4.2.1(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.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)):
|
||||
|
|
@ -31219,6 +31693,14 @@ snapshots:
|
|||
postcss: 8.5.6
|
||||
ts-node: 10.9.2(@types/node@20.19.25)(typescript@5.9.3)
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.8.1
|
||||
optionalDependencies:
|
||||
postcss: 8.5.6
|
||||
ts-node: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
|
|
@ -33924,10 +34406,18 @@ 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.6:
|
||||
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: {}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,71 @@
|
|||
# Build Stage
|
||||
# =============================================================================
|
||||
# uload Web Application Dockerfile
|
||||
# Multi-stage build for production deployment with Coolify
|
||||
#
|
||||
# IMPORTANT: This Dockerfile must be built from the MONOREPO ROOT, not from uload/
|
||||
# docker build -f uload/Dockerfile -t uload-web .
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files from apps/web
|
||||
COPY apps/web/package*.json ./
|
||||
# Copy workspace configuration
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --legacy-peer-deps
|
||||
# Copy the uload web app
|
||||
COPY uload/apps/web/ ./uload/apps/web/
|
||||
|
||||
# Copy web app source
|
||||
COPY apps/web/ .
|
||||
# Copy required shared packages
|
||||
COPY packages/shared-auth-ui/ ./packages/shared-auth-ui/
|
||||
COPY packages/shared-branding/ ./packages/shared-branding/
|
||||
|
||||
# Generate .svelte-kit directory first by running vite in prepare mode
|
||||
RUN npx vite build --mode prepare || true
|
||||
|
||||
# Sync SvelteKit files
|
||||
RUN npx svelte-kit sync
|
||||
|
||||
# Compile paraglide messages before build
|
||||
RUN npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/paraglide
|
||||
# Install dependencies with flat structure for Docker compatibility
|
||||
RUN pnpm install --filter @uload/web... --shamefully-hoist
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
WORKDIR /app/uload/apps/web
|
||||
|
||||
# Production Stage
|
||||
FROM node:20-alpine
|
||||
# Note: RESEND_API_KEY is needed at build time for SvelteKit prerendering
|
||||
ENV RESEND_API_KEY=build_placeholder
|
||||
RUN pnpm build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Production Runner
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
# Security: Run as non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 sveltekit
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built app and dependencies
|
||||
COPY --from=builder /app/build build/
|
||||
COPY --from=builder /app/package*.json ./
|
||||
COPY --from=builder /app/node_modules node_modules/
|
||||
COPY --from=builder /app/drizzle drizzle/
|
||||
# Copy built app from the correct path
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/build ./build
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/uload/apps/web/package.json ./
|
||||
|
||||
# Copy hoisted node_modules from root (contains all deps with flat structure)
|
||||
COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {r.statusCode === 200 ? process.exit(0) : process.exit(1)})"
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Switch to non-root user
|
||||
USER sveltekit
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
|
|
|||
22
uload/apps/backend/.env.example
Normal file
22
uload/apps/backend/.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3003
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload
|
||||
|
||||
# Redis (for caching)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=your-uload-app-id
|
||||
MANA_SERVICE_KEY=
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Short URL base (for generating short links)
|
||||
SHORT_URL_BASE=https://ulo.ad
|
||||
65
uload/apps/backend/Dockerfile
Normal file
65
uload/apps/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Copy workspace packages
|
||||
COPY packages/uload-database ./packages/uload-database
|
||||
|
||||
# Copy backend source
|
||||
COPY uload/apps/backend ./uload/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the database package first
|
||||
WORKDIR /app/packages/uload-database
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/uload/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/package.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/node_modules ./node_modules
|
||||
|
||||
# Copy database package (needed at runtime)
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/dist ./node_modules/@manacore/uload-database/dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/package.json ./node_modules/@manacore/uload-database/
|
||||
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3003
|
||||
|
||||
# Start with dumb-init
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main"]
|
||||
8
uload/apps/backend/nest-cli.json
Normal file
8
uload/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
71
uload/apps/backend/package.json
Normal file
71
uload/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "@uload/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "ULOAD URL Shortener Backend",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
|
||||
"@manacore/uload-database": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^18.0.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-cls": "^6.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ua-parser-js": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
80
uload/apps/backend/src/app.module.ts
Normal file
80
uload/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ManaCoreModule } from '@mana-core/nestjs-integration';
|
||||
|
||||
import { validationSchema } from './config/validation.schema';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { LinkRepository } from './database/repositories/link.repository';
|
||||
import { ClickRepository } from './database/repositories/click.repository';
|
||||
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { RedirectController } from './controllers/redirect.controller';
|
||||
import { LinksController } from './controllers/links.controller';
|
||||
import { AnalyticsController } from './controllers/analytics.controller';
|
||||
|
||||
import { LinksService } from './services/links.service';
|
||||
import { RedirectService } from './services/redirect.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Context-Local Storage for request-scoped data
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true, generateId: true },
|
||||
}),
|
||||
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema,
|
||||
validationOptions: {
|
||||
allowUnknown: true,
|
||||
abortEarly: false,
|
||||
},
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
|
||||
// Mana Core Authentication
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
manaServiceUrl: configService.get<string>('MANA_SERVICE_URL')!,
|
||||
appId: configService.get<string>('APP_ID')!,
|
||||
serviceKey: configService.get<string>('MANA_SERVICE_KEY', ''),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}) as any,
|
||||
|
||||
// Health checks
|
||||
TerminusModule,
|
||||
HttpModule,
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [
|
||||
HealthController,
|
||||
RedirectController,
|
||||
LinksController,
|
||||
AnalyticsController,
|
||||
],
|
||||
providers: [
|
||||
// Repositories
|
||||
LinkRepository,
|
||||
ClickRepository,
|
||||
// Services
|
||||
LinksService,
|
||||
RedirectService,
|
||||
AnalyticsService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// Add custom middleware here if needed
|
||||
}
|
||||
}
|
||||
28
uload/apps/backend/src/config/validation.schema.ts
Normal file
28
uload/apps/backend/src/config/validation.schema.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as Joi from 'joi';
|
||||
|
||||
export const validationSchema = Joi.object({
|
||||
// Server
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test')
|
||||
.default('development'),
|
||||
PORT: Joi.number().default(3003),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: Joi.string().uri().required(),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: Joi.string().default('localhost'),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().allow('').optional(),
|
||||
|
||||
// Mana Core Auth
|
||||
MANA_SERVICE_URL: Joi.string().uri().required(),
|
||||
APP_ID: Joi.string().uuid().required(),
|
||||
MANA_SERVICE_KEY: Joi.string().allow('').optional(),
|
||||
|
||||
// Frontend
|
||||
FRONTEND_URL: Joi.string().uri().optional(),
|
||||
|
||||
// Short URL
|
||||
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
|
||||
});
|
||||
98
uload/apps/backend/src/controllers/analytics.controller.ts
Normal file
98
uload/apps/backend/src/controllers/analytics.controller.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
import { LinksService } from '../services/links.service';
|
||||
|
||||
@Controller('api/analytics')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly linksService: LinksService,
|
||||
) {}
|
||||
|
||||
@Get('links/:linkId')
|
||||
async getLinkAnalytics(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('from') fromDate?: string,
|
||||
@Query('to') toDate?: string,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const stats = await this.analyticsService.getStats(
|
||||
linkId,
|
||||
fromDate ? new Date(fromDate) : undefined,
|
||||
toDate ? new Date(toDate) : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
shortCode: link.shortCode,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('links/:linkId/clicks')
|
||||
async getLinkClicks(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('limit') limit: number = 100,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const { clicks, total } = await this.analyticsService.getRecentClicks(
|
||||
linkId,
|
||||
limit,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
clicks: clicks.map((click) => ({
|
||||
...click,
|
||||
ipHash: undefined, // Don't expose IP hash
|
||||
})),
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: any) {
|
||||
const userId = user.sub;
|
||||
const totalLinks = await this.linksService.getLinkCount(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalLinks,
|
||||
// Add more overview stats as needed
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
35
uload/apps/backend/src/controllers/health.controller.ts
Normal file
35
uload/apps/backend/src/controllers/health.controller.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
HealthCheckService,
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private health: HealthCheckService) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check(): Promise<HealthCheckResult> {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'live',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
}
|
||||
}
|
||||
131
uload/apps/backend/src/controllers/links.controller.ts
Normal file
131
uload/apps/backend/src/controllers/links.controller.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { LinksService, type CreateLinkDto, type UpdateLinkDto } from '../services/links.service';
|
||||
|
||||
@Controller('api/links')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LinksController {
|
||||
constructor(private readonly linksService: LinksService) {}
|
||||
|
||||
@Get()
|
||||
async getLinks(
|
||||
@CurrentUser() user: any,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 20,
|
||||
@Query('search') search?: string,
|
||||
@Query('isActive') isActive?: boolean,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
const { items, total } = await this.linksService.getLinks(userId, {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
isActive,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
links: items.map((link) => ({
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined, // Never send password to client
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.getLinkById(id, userId);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createLink(@CurrentUser() user: any, @Body() dto: CreateLinkDto) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.createLink(userId, dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLink(
|
||||
@CurrentUser() user: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLinkDto,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.updateLink(id, userId, dto);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const deleted = await this.linksService.deleteLink(id, userId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Link deleted successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
113
uload/apps/backend/src/controllers/redirect.controller.ts
Normal file
113
uload/apps/backend/src/controllers/redirect.controller.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
HttpStatus,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { Response, Request } from 'express';
|
||||
import { RedirectService } from '../services/redirect.service';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
|
||||
@Controller()
|
||||
export class RedirectController {
|
||||
constructor(
|
||||
private readonly redirectService: RedirectService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
@Get(':code')
|
||||
async redirect(
|
||||
@Param('code') code: string,
|
||||
@Query('utm_source') utmSource: string,
|
||||
@Query('utm_medium') utmMedium: string,
|
||||
@Query('utm_campaign') utmCampaign: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
// Skip for API and health routes
|
||||
if (code === 'v1' || code === 'health') {
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.redirectService.getRedirect(code);
|
||||
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'not_found':
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'Link not found',
|
||||
});
|
||||
|
||||
case 'expired':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has expired',
|
||||
});
|
||||
|
||||
case 'inactive':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link is no longer active',
|
||||
});
|
||||
|
||||
case 'max_clicks':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has reached its maximum clicks',
|
||||
});
|
||||
|
||||
case 'password_required':
|
||||
return response.status(HttpStatus.OK).json({
|
||||
success: false,
|
||||
passwordRequired: true,
|
||||
linkId: result.linkId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Record click asynchronously (don't wait)
|
||||
this.analyticsService
|
||||
.recordClick(result.linkId!, {
|
||||
userAgent: request.headers['user-agent'] || '',
|
||||
referer: request.headers['referer'] || '',
|
||||
ip: request.ip,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
})
|
||||
.catch((err) => console.error('Failed to record click:', err));
|
||||
|
||||
// Perform redirect
|
||||
return response.redirect(302, result.targetUrl!);
|
||||
}
|
||||
|
||||
@Post(':code/unlock')
|
||||
async unlockLink(
|
||||
@Param('code') code: string,
|
||||
@Body('password') password: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const result = await this.redirectService.verifyPassword(code, password);
|
||||
|
||||
if (!result.success) {
|
||||
return response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
});
|
||||
}
|
||||
|
||||
return response.json({
|
||||
success: true,
|
||||
targetUrl: result.targetUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
29
uload/apps/backend/src/database/database.module.ts
Normal file
29
uload/apps/backend/src/database/database.module.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { getDb, closeDb, type Database } from '@manacore/uload-database';
|
||||
|
||||
export const DATABASE_TOKEN = 'DATABASE';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: () => {
|
||||
const logger = new Logger('DatabaseModule');
|
||||
logger.log('Initializing database connection');
|
||||
return getDb();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
export type { Database };
|
||||
162
uload/apps/backend/src/database/repositories/click.repository.ts
Normal file
162
uload/apps/backend/src/database/repositories/click.repository.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
clicks,
|
||||
type Click,
|
||||
type NewClick,
|
||||
eq,
|
||||
desc,
|
||||
sql,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
} from '@manacore/uload-database';
|
||||
|
||||
export interface ClickStats {
|
||||
totalClicks: number;
|
||||
uniqueVisitors: number;
|
||||
topCountries: { country: string; count: number }[];
|
||||
topBrowsers: { browser: string; count: number }[];
|
||||
topDevices: { deviceType: string; count: number }[];
|
||||
clicksByDay: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClickRepository {
|
||||
private readonly logger = new Logger(ClickRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async create(data: NewClick): Promise<Click> {
|
||||
const result = await this.db.insert(clicks).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async findByLinkId(
|
||||
linkId: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
): Promise<Click[]> {
|
||||
const { limit = 100, offset = 0 } = options;
|
||||
return this.db
|
||||
.select()
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.orderBy(desc(clicks.clickedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async countByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
async getStats(
|
||||
linkId: string,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): Promise<ClickStats> {
|
||||
const conditions = [eq(clicks.linkId, linkId)];
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(gte(clicks.clickedAt, fromDate));
|
||||
}
|
||||
if (toDate) {
|
||||
conditions.push(lte(clicks.clickedAt, toDate));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Total clicks
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Unique visitors (by IP hash)
|
||||
const uniqueResult = await this.db
|
||||
.select({ count: sql<number>`count(distinct ${clicks.ipHash})::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Top countries
|
||||
const countriesResult = await this.db
|
||||
.select({
|
||||
country: clicks.country,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.country)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top browsers
|
||||
const browsersResult = await this.db
|
||||
.select({
|
||||
browser: clicks.browser,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.browser)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top devices
|
||||
const devicesResult = await this.db
|
||||
.select({
|
||||
deviceType: clicks.deviceType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.deviceType)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Clicks by day (last 30 days)
|
||||
const clicksByDayResult = await this.db
|
||||
.select({
|
||||
date: sql<string>`date_trunc('day', ${clicks.clickedAt})::date::text`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.orderBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.limit(30);
|
||||
|
||||
return {
|
||||
totalClicks: totalResult[0]?.count || 0,
|
||||
uniqueVisitors: uniqueResult[0]?.count || 0,
|
||||
topCountries: countriesResult.map((r) => ({
|
||||
country: r.country || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topBrowsers: browsersResult.map((r) => ({
|
||||
browser: r.browser || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topDevices: devicesResult.map((r) => ({
|
||||
deviceType: r.deviceType || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
clicksByDay: clicksByDayResult.map((r) => ({
|
||||
date: r.date,
|
||||
count: r.count,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.returning({ id: clicks.id });
|
||||
return result.length;
|
||||
}
|
||||
}
|
||||
2
uload/apps/backend/src/database/repositories/index.ts
Normal file
2
uload/apps/backend/src/database/repositories/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { LinkRepository, type ListLinksOptions } from './link.repository';
|
||||
export { ClickRepository, type ClickStats } from './click.repository';
|
||||
148
uload/apps/backend/src/database/repositories/link.repository.ts
Normal file
148
uload/apps/backend/src/database/repositories/link.repository.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
links,
|
||||
type Link,
|
||||
type NewLink,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
or,
|
||||
ilike,
|
||||
} from '@manacore/uload-database';
|
||||
|
||||
export interface ListLinksOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinkRepository {
|
||||
private readonly logger = new Logger(LinkRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByShortCode(shortCode: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(eq(links.id, id))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
options: ListLinksOptions = {},
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
const { page = 1, limit = 20, search, isActive } = options;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(links.userId, userId)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(links.title, `%${search}%`),
|
||||
ilike(links.originalUrl, `%${search}%`),
|
||||
ilike(links.shortCode, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
conditions.push(eq(links.isActive, isActive));
|
||||
}
|
||||
|
||||
const [countResult, items] = await Promise.all([
|
||||
this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(and(...conditions)),
|
||||
this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(links.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: countResult[0]?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: NewLink): Promise<Link> {
|
||||
this.logger.debug(`Creating link: ${data.shortCode}`);
|
||||
const result = await this.db.insert(links).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Omit<NewLink, 'id' | 'userId' | 'createdAt'>>,
|
||||
): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.update(links)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning({ id: links.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async incrementClickCount(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(links)
|
||||
.set({ clickCount: sql`${links.clickCount} + 1` })
|
||||
.where(eq(links.id, id));
|
||||
}
|
||||
|
||||
async isShortCodeAvailable(shortCode: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ id: links.id })
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(eq(links.userId, userId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
47
uload/apps/backend/src/main.ts
Normal file
47
uload/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// CORS configuration
|
||||
app.enableCors({
|
||||
origin: configService.get('FRONTEND_URL') || true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Global prefix for API routes (except health and redirect)
|
||||
app.setGlobalPrefix('v1', {
|
||||
exclude: ['health', 'health/(.*)', ':code'],
|
||||
});
|
||||
|
||||
const port = configService.get('PORT') || 3003;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`ULOAD Backend running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
102
uload/apps/backend/src/services/analytics.service.ts
Normal file
102
uload/apps/backend/src/services/analytics.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as UAParser from 'ua-parser-js';
|
||||
import { ClickRepository, type ClickStats } from '../database/repositories';
|
||||
import { RedirectService } from './redirect.service';
|
||||
import type { NewClick } from '@manacore/uload-database';
|
||||
|
||||
export interface RecordClickData {
|
||||
userAgent: string;
|
||||
referer?: string;
|
||||
ip?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickRepository: ClickRepository,
|
||||
private readonly redirectService: RedirectService,
|
||||
) {}
|
||||
|
||||
async recordClick(linkId: string, data: RecordClickData): Promise<void> {
|
||||
try {
|
||||
// Parse user agent
|
||||
const parser = new UAParser.UAParser(data.userAgent);
|
||||
const browser = parser.getBrowser();
|
||||
const os = parser.getOS();
|
||||
const device = parser.getDevice();
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = data.ip ? this.hashIp(data.ip) : null;
|
||||
|
||||
// Determine device type
|
||||
let deviceType = 'desktop';
|
||||
if (device.type === 'mobile') {
|
||||
deviceType = 'mobile';
|
||||
} else if (device.type === 'tablet') {
|
||||
deviceType = 'tablet';
|
||||
}
|
||||
|
||||
const clickData: NewClick = {
|
||||
linkId,
|
||||
ipHash,
|
||||
userAgent: data.userAgent,
|
||||
referer: data.referer,
|
||||
browser: browser.name || 'Unknown',
|
||||
deviceType,
|
||||
os: os.name || 'Unknown',
|
||||
// TODO: Geo lookup from IP
|
||||
country: null,
|
||||
city: null,
|
||||
utmSource: data.utmSource,
|
||||
utmMedium: data.utmMedium,
|
||||
utmCampaign: data.utmCampaign,
|
||||
};
|
||||
|
||||
await this.clickRepository.create(clickData);
|
||||
|
||||
// Increment click count on the link
|
||||
await this.redirectService.incrementClickCount(linkId);
|
||||
|
||||
this.logger.debug(`Recorded click for link ${linkId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to record click for link ${linkId}:`, error);
|
||||
// Don't throw - click recording should not block redirect
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(
|
||||
linkId: string,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): Promise<ClickStats> {
|
||||
return this.clickRepository.getStats(linkId, fromDate, toDate);
|
||||
}
|
||||
|
||||
async getRecentClicks(
|
||||
linkId: string,
|
||||
limit: number = 100,
|
||||
): Promise<{ clicks: any[]; total: number }> {
|
||||
const [clicks, total] = await Promise.all([
|
||||
this.clickRepository.findByLinkId(linkId, { limit }),
|
||||
this.clickRepository.countByLinkId(linkId),
|
||||
]);
|
||||
|
||||
return { clicks, total };
|
||||
}
|
||||
|
||||
private hashIp(ip: string): string {
|
||||
// Simple hash for privacy - in production use a proper hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < ip.length; i++) {
|
||||
const char = ip.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
144
uload/apps/backend/src/services/links.service.ts
Normal file
144
uload/apps/backend/src/services/links.service.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { LinkRepository, type ListLinksOptions } from '../database/repositories';
|
||||
import type { Link, NewLink } from '@manacore/uload-database';
|
||||
|
||||
export interface CreateLinkDto {
|
||||
originalUrl: string;
|
||||
customCode?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLinkDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
isActive?: boolean;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinksService {
|
||||
private readonly logger = new Logger(LinksService.name);
|
||||
private readonly shortUrlBase: string;
|
||||
|
||||
constructor(
|
||||
private readonly linkRepository: LinkRepository,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.shortUrlBase = this.configService.get('SHORT_URL_BASE', 'https://ulo.ad');
|
||||
}
|
||||
|
||||
async createLink(userId: string, dto: CreateLinkDto): Promise<Link> {
|
||||
// Generate or validate short code
|
||||
let shortCode = dto.customCode;
|
||||
|
||||
if (shortCode) {
|
||||
// Validate custom code format
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(shortCode)) {
|
||||
throw new BadRequestException(
|
||||
'Custom code can only contain letters, numbers, hyphens and underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if custom code is available
|
||||
const isAvailable = await this.linkRepository.isShortCodeAvailable(shortCode);
|
||||
if (!isAvailable) {
|
||||
throw new BadRequestException('This custom code is already taken');
|
||||
}
|
||||
} else {
|
||||
// Generate random short code
|
||||
shortCode = nanoid(7);
|
||||
|
||||
// Make sure it's unique (very unlikely to collide, but check anyway)
|
||||
let attempts = 0;
|
||||
while (
|
||||
!(await this.linkRepository.isShortCodeAvailable(shortCode)) &&
|
||||
attempts < 5
|
||||
) {
|
||||
shortCode = nanoid(7);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
const newLink: NewLink = {
|
||||
shortCode,
|
||||
customCode: dto.customCode,
|
||||
originalUrl: dto.originalUrl,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
userId,
|
||||
password: dto.password, // TODO: Hash password if provided
|
||||
maxClicks: dto.maxClicks,
|
||||
expiresAt: dto.expiresAt,
|
||||
tags: dto.tags,
|
||||
utmSource: dto.utmSource,
|
||||
utmMedium: dto.utmMedium,
|
||||
utmCampaign: dto.utmCampaign,
|
||||
workspaceId: dto.workspaceId,
|
||||
};
|
||||
|
||||
const link = await this.linkRepository.create(newLink);
|
||||
this.logger.log(`Created link ${link.shortCode} for user ${userId}`);
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async updateLink(
|
||||
id: string,
|
||||
userId: string,
|
||||
dto: UpdateLinkDto,
|
||||
): Promise<Link | null> {
|
||||
const link = await this.linkRepository.update(id, userId, dto);
|
||||
|
||||
if (link) {
|
||||
this.logger.log(`Updated link ${link.shortCode} for user ${userId}`);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async deleteLink(id: string, userId: string): Promise<boolean> {
|
||||
const deleted = await this.linkRepository.delete(id, userId);
|
||||
|
||||
if (deleted) {
|
||||
this.logger.log(`Deleted link ${id} for user ${userId}`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async getLinkById(id: string, userId: string): Promise<Link | null> {
|
||||
return this.linkRepository.findByIdAndUserId(id, userId);
|
||||
}
|
||||
|
||||
async getLinks(
|
||||
userId: string,
|
||||
options: ListLinksOptions,
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
return this.linkRepository.findByUserId(userId, options);
|
||||
}
|
||||
|
||||
async getLinkCount(userId: string): Promise<number> {
|
||||
return this.linkRepository.countByUserId(userId);
|
||||
}
|
||||
|
||||
getShortUrl(shortCode: string): string {
|
||||
return `${this.shortUrlBase}/${shortCode}`;
|
||||
}
|
||||
}
|
||||
77
uload/apps/backend/src/services/redirect.service.ts
Normal file
77
uload/apps/backend/src/services/redirect.service.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LinkRepository } from '../database/repositories';
|
||||
import type { Link } from '@manacore/uload-database';
|
||||
|
||||
export interface RedirectResult {
|
||||
success: boolean;
|
||||
targetUrl?: string;
|
||||
linkId?: string;
|
||||
error?: 'not_found' | 'expired' | 'inactive' | 'max_clicks' | 'password_required';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedirectService {
|
||||
private readonly logger = new Logger(RedirectService.name);
|
||||
|
||||
constructor(private readonly linkRepository: LinkRepository) {}
|
||||
|
||||
async getRedirect(shortCode: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// Check if link is active
|
||||
if (!link.isActive) {
|
||||
return { success: false, error: 'inactive', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if link has expired
|
||||
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
|
||||
return { success: false, error: 'expired', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check max clicks
|
||||
if (link.maxClicks && (link.clickCount ?? 0) >= link.maxClicks) {
|
||||
return { success: false, error: 'max_clicks', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if password protected
|
||||
if (link.password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(
|
||||
shortCode: string,
|
||||
password: string,
|
||||
): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// TODO: Compare hashed passwords
|
||||
if (link.password !== password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async incrementClickCount(linkId: string): Promise<void> {
|
||||
await this.linkRepository.incrementClickCount(linkId);
|
||||
}
|
||||
}
|
||||
23
uload/apps/backend/tsconfig.json
Normal file
23
uload/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
20
uload/apps/landing/astro.config.mjs
Normal file
20
uload/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
24
uload/apps/landing/package.json
Normal file
24
uload/apps/landing/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"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",
|
||||
"astro": "^5.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
76
uload/apps/landing/src/components/FeaturesSection.astro
Normal file
76
uload/apps/landing/src/components/FeaturesSection.astro
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
const features = [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'URL-Verkürzung',
|
||||
description: 'Verwandeln Sie lange URLs in kurze, teilbare Links mit nur einem Klick. Perfekt für Social Media und Marketing.'
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Detaillierte Analytics',
|
||||
description: 'Verfolgen Sie Klicks, geografische Herkunft, Geräte und Engagement Ihrer Links in Echtzeit.'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'QR-Code Generator',
|
||||
description: 'Erstellen Sie anpassbare QR-Codes in verschiedenen Farben und Formaten für jeden Link.'
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
title: 'Digitale Visitenkarten',
|
||||
description: 'Erstellen Sie professionelle digitale Visitenkarten mit QR-Codes und Kontaktinformationen.'
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Passwortschutz',
|
||||
description: 'Schützen Sie Ihre Links mit Passwörtern und setzen Sie Ablaufdaten für zeitlich begrenzte Aktionen.'
|
||||
},
|
||||
{
|
||||
icon: '🏷️',
|
||||
title: 'Tag-System',
|
||||
description: 'Organisieren Sie Ihre Links mit Tags und Kategorien für eine bessere Übersicht und Filterung.'
|
||||
},
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Team Workspaces',
|
||||
description: 'Arbeiten Sie im Team zusammen mit gemeinsamen Workspaces und granularen Berechtigungen.'
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Blitzschnell',
|
||||
description: 'Unsere Links sind weltweit über ein CDN verteilt für minimale Ladezeiten und maximale Verfügbarkeit.'
|
||||
},
|
||||
{
|
||||
icon: '🔌',
|
||||
title: 'API Zugang',
|
||||
description: 'Integrieren Sie uLoad in Ihre Anwendungen mit unserer RESTful API für automatisierte Workflows.'
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section id="features" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="mb-4 text-3xl font-bold text-gray-900 sm:text-4xl">
|
||||
Alles was du für professionelles Link-Management brauchst
|
||||
</h2>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Von einfacher URL-Verkürzung bis hin zu Team-Kollaboration – uLoad bietet alle Features die du brauchst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map(feature => (
|
||||
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl hover:border-primary-200">
|
||||
<div class="mb-4 text-4xl">{feature.icon}</div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="text-gray-600">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
95
uload/apps/landing/src/components/Footer.astro
Normal file
95
uload/apps/landing/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
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';
|
||||
---
|
||||
|
||||
<footer class="bg-gray-900 text-gray-300">
|
||||
<div class="container-custom py-12 md:py-16">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-2 md:col-span-1">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-lg">u</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-white">uLoad</span>
|
||||
</a>
|
||||
<p class="text-sm text-gray-400 mb-4">
|
||||
Der intelligente URL-Shortener für Profis. Verkürzen Sie Links, erstellen Sie QR-Codes und analysieren Sie Klicks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Produkt -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.produkt.map(link => (
|
||||
<li>
|
||||
<a href={link.href} class="text-gray-400 hover:text-white transition-colors text-sm">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Unternehmen -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Unternehmen</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.unternehmen.map(link => (
|
||||
<li>
|
||||
<a href={link.href} class="text-gray-400 hover:text-white transition-colors text-sm">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Rechtliches -->
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.rechtliches.map(link => (
|
||||
<li>
|
||||
<a href={link.href} class="text-gray-400 hover:text-white transition-colors text-sm">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="border-t border-gray-800 mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p class="text-sm text-gray-400">
|
||||
© {currentYear} uLoad. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href={`${appUrl}/login`} class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
App öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
138
uload/apps/landing/src/components/HeroSection.astro
Normal file
138
uload/apps/landing/src/components/HeroSection.astro
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<!-- Background decoration -->
|
||||
<div class="absolute inset-0 -z-10">
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-96 w-96 rounded-full bg-primary-500/10 blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-0 translate-x-1/3 translate-y-1/3 h-96 w-96 rounded-full bg-purple-600/10 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<!-- Trust badges -->
|
||||
<div class="mb-6 flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
DSGVO-konform
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Blitzschnell
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
100% Sicher
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main headline -->
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
More than links.
|
||||
<span class="bg-gradient-to-r from-primary-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Your digital identity.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="mx-auto mb-8 max-w-2xl text-lg text-gray-600 sm:text-xl">
|
||||
Der einzige Link-Shortener mit integriertem Profile-Builder.
|
||||
Erstelle kurze Links, beeindruckende Profilkarten und manage alles im Team.
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="mb-12 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700 hover:shadow-xl"
|
||||
>
|
||||
Kostenlos starten →
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="rounded-lg border-2 border-gray-200 bg-white px-8 py-3 font-semibold text-gray-900 transition hover:border-primary-500 hover:shadow-lg"
|
||||
>
|
||||
Features entdecken
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Shortener teaser -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white/80 p-4 backdrop-blur sm:flex-row sm:p-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Deine lange URL hier einfügen..."
|
||||
disabled
|
||||
class="flex-1 rounded-lg border-0 bg-transparent px-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none sm:py-2"
|
||||
/>
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-6 py-3 font-medium text-white transition hover:bg-primary-700 sm:py-2 text-center"
|
||||
>
|
||||
Kürzen →
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Keine Anmeldung erforderlich • Kostenlos • QR-Code inklusive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual preview -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<!-- Link shortening preview -->
|
||||
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-100">
|
||||
<svg class="h-6 w-6 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Smart Links</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Kurze URLs mit Tracking, Ablaufdatum und Passwortschutz
|
||||
</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-primary-600 group-hover:underline">
|
||||
Mehr erfahren →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Profile cards preview -->
|
||||
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||
<svg class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Profile Cards</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Beeindruckende Profilseiten mit Drag & Drop Builder
|
||||
</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-purple-600 group-hover:underline">
|
||||
Templates ansehen →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Team collaboration preview -->
|
||||
<div class="group relative rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-xl">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="mb-2 font-semibold text-gray-900">Team Workspace</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Gemeinsam Links verwalten mit granularen Berechtigungen
|
||||
</p>
|
||||
<a href="/features" class="mt-4 inline-block text-xs text-green-600 group-hover:underline">
|
||||
Für Teams →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
87
uload/apps/landing/src/components/Navigation.astro
Normal file
87
uload/apps/landing/src/components/Navigation.astro
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '/features', label: 'Features' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/about', label: 'Über uns' },
|
||||
];
|
||||
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-100">
|
||||
<nav class="container-custom">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-lg">u</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold text-gray-900">uLoad</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<a href={`${appUrl}/login`} class="text-gray-600 hover:text-gray-900 font-medium">
|
||||
Anmelden
|
||||
</a>
|
||||
<a href={`${appUrl}/register`} class="btn-primary">
|
||||
Kostenlos starten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="md:hidden p-2 text-gray-600 hover:text-gray-900"
|
||||
aria-label="Menü öffnen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4">
|
||||
<div class="flex flex-col gap-4">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-gray-600 hover:text-gray-900 font-medium py-2"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<div class="flex flex-col gap-2 pt-4 border-t border-gray-100">
|
||||
<a href={`${appUrl}/login`} class="btn-secondary text-center">
|
||||
Anmelden
|
||||
</a>
|
||||
<a href={`${appUrl}/register`} class="btn-primary text-center">
|
||||
Kostenlos starten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
const menuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
menuBtn?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
185
uload/apps/landing/src/components/PricingSection.astro
Normal file
185
uload/apps/landing/src/components/PricingSection.astro
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
---
|
||||
const appUrl = 'https://app.ulo.ad';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
'10 Links pro Monat',
|
||||
'Basis Analytics',
|
||||
'QR-Code Generator',
|
||||
'Link Anpassung',
|
||||
'Standard Support'
|
||||
],
|
||||
cta: 'Kostenlos starten',
|
||||
highlighted: false,
|
||||
href: `${appUrl}/register`
|
||||
},
|
||||
{
|
||||
id: 'pro-monthly',
|
||||
name: 'Pro',
|
||||
price: 4.99,
|
||||
period: '/Monat',
|
||||
description: 'Für Freelancer & Creators',
|
||||
features: [
|
||||
'Unbegrenzte Links',
|
||||
'Erweiterte Analytics',
|
||||
'Custom QR Codes',
|
||||
'Link Anpassung',
|
||||
'Priority Support',
|
||||
'API Zugang'
|
||||
],
|
||||
cta: 'Pro wählen',
|
||||
highlighted: false,
|
||||
href: `${appUrl}/register?plan=pro`
|
||||
},
|
||||
{
|
||||
id: 'pro-yearly',
|
||||
name: 'Pro Jährlich',
|
||||
price: 3.33,
|
||||
period: '/Monat',
|
||||
description: 'Beste Wahl für Power User',
|
||||
features: [
|
||||
'Unbegrenzte Links',
|
||||
'Erweiterte Analytics',
|
||||
'Custom QR Codes',
|
||||
'Link Anpassung',
|
||||
'Priority Support',
|
||||
'API Zugang'
|
||||
],
|
||||
cta: 'Jährlich sparen',
|
||||
highlighted: true,
|
||||
badge: 'Spare 20€/Jahr',
|
||||
href: `${appUrl}/register?plan=pro-yearly`
|
||||
},
|
||||
{
|
||||
id: 'lifetime',
|
||||
name: 'Pro Lifetime',
|
||||
price: 129.99,
|
||||
period: 'einmalig',
|
||||
description: 'Einmalig zahlen, für immer nutzen',
|
||||
features: [
|
||||
'Alle Pro Features',
|
||||
'Lebenslanger Zugang',
|
||||
'Alle zukünftigen Features',
|
||||
'Early Access',
|
||||
'Priority Support'
|
||||
],
|
||||
cta: 'Lifetime sichern',
|
||||
highlighted: false,
|
||||
badge: 'Einmalig',
|
||||
href: `${appUrl}/register?plan=lifetime`
|
||||
}
|
||||
];
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: price % 1 === 0 ? 0 : 2
|
||||
}).format(price);
|
||||
}
|
||||
---
|
||||
|
||||
<section id="pricing" class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24 bg-gray-50">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-gray-900 sm:text-4xl">
|
||||
Transparente Preise, keine versteckten Kosten
|
||||
</h2>
|
||||
<p class="mx-auto mb-12 max-w-2xl text-lg text-gray-600">
|
||||
Starte kostenlos und upgrade wenn du bereit bist. Jederzeit kündbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Cards -->
|
||||
<div class="grid gap-8 lg:grid-cols-4">
|
||||
{plans.map(plan => (
|
||||
<div
|
||||
class:list={[
|
||||
"relative rounded-xl border-2 bg-white transition-all duration-300",
|
||||
plan.highlighted
|
||||
? "border-primary-500 shadow-2xl scale-105"
|
||||
: "border-gray-200 hover:border-primary-300 hover:shadow-xl"
|
||||
]}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<span class="rounded-full bg-primary-600 px-4 py-1 text-xs font-semibold text-white">
|
||||
{plan.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="p-6">
|
||||
<h3 class="mb-2 text-xl font-bold text-gray-900">{plan.name}</h3>
|
||||
<p class="mb-4 text-sm text-gray-500">{plan.description}</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline">
|
||||
<span class="text-4xl font-bold text-gray-900">
|
||||
{formatPrice(plan.price)}
|
||||
</span>
|
||||
<span class="ml-2 text-gray-500">{plan.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={plan.href}
|
||||
class:list={[
|
||||
"mb-6 block w-full rounded-lg py-3 font-semibold text-center transition",
|
||||
plan.highlighted
|
||||
? "bg-primary-600 text-white hover:bg-primary-700"
|
||||
: "border-2 border-gray-200 text-gray-900 hover:border-primary-500 hover:bg-primary-50"
|
||||
]}
|
||||
>
|
||||
{plan.cta}
|
||||
</a>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Inklusive:
|
||||
</p>
|
||||
{plan.features.map(feature => (
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Benefits -->
|
||||
<div class="mt-16 rounded-xl border border-gray-200 bg-white p-8">
|
||||
<div class="grid gap-8 lg:grid-cols-3">
|
||||
<div>
|
||||
<h4 class="mb-2 font-semibold text-gray-900">💳 Keine Kreditkarte erforderlich</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
Starte komplett kostenlos. Upgrade nur wenn du mehr brauchst.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-2 font-semibold text-gray-900">🔄 Jederzeit kündbar</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
Keine Vertragsbindung. Kündige monatlich ohne Probleme.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-2 font-semibold text-gray-900">🚀 Sofort startklar</h4>
|
||||
<p class="text-sm text-gray-600">
|
||||
Nach der Anmeldung kannst du sofort alle Features nutzen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
85
uload/apps/landing/src/content/blog/link-tracking-guide.md
Normal file
85
uload/apps/landing/src/content/blog/link-tracking-guide.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
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
|
||||
17
uload/apps/landing/src/content/config.ts
Normal file
17
uload/apps/landing/src/content/config.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
2
uload/apps/landing/src/env.d.ts
vendored
Normal file
2
uload/apps/landing/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
52
uload/apps/landing/src/layouts/BaseLayout.astro
Normal file
52
uload/apps/landing/src/layouts/BaseLayout.astro
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
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);
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<title>{title} | uLoad</title>
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={new URL(ogImage, Astro.site)} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<Navigation />
|
||||
<main class="flex-grow">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
32
uload/apps/landing/src/layouts/LegalLayout.astro
Normal file
32
uload/apps/landing/src/layouts/LegalLayout.astro
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
const { title, description, lastUpdated } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<header class="mb-12">
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
{lastUpdated && (
|
||||
<p class="text-gray-500">
|
||||
Zuletzt aktualisiert: {lastUpdated}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="prose prose-lg prose-gray max-w-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
124
uload/apps/landing/src/pages/about.astro
Normal file
124
uload/apps/landing/src/pages/about.astro
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
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.'
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Über uns" description="Erfahren Sie mehr über uLoad - den intelligenten URL-Shortener für Profis.">
|
||||
<!-- Hero -->
|
||||
<section class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Links die verbinden
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
uLoad wurde entwickelt um Link-Management einfach, sicher und effektiv zu machen.
|
||||
Für Einzelpersonen, Teams und Unternehmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="bg-primary-600 px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
{stats.map(stat => (
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-white">{stat.value}</div>
|
||||
<div class="mt-1 text-primary-100">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Story -->
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="mb-8 text-center text-3xl font-bold text-gray-900">
|
||||
Unsere Geschichte
|
||||
</h2>
|
||||
<div class="prose prose-lg mx-auto text-gray-600">
|
||||
<p>
|
||||
uLoad entstand aus einer einfachen Frustration: Bestehende URL-Shortener waren entweder
|
||||
zu kompliziert, zu teuer oder boten nicht die Features die moderne Teams brauchen.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values -->
|
||||
<section class="bg-gray-50 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">
|
||||
Unsere Werte
|
||||
</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{values.map(value => (
|
||||
<div class="rounded-xl bg-white p-6 shadow-sm">
|
||||
<div class="mb-4 text-4xl">{value.icon}</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">{value.title}</h3>
|
||||
<p class="text-sm text-gray-600">{value.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-gray-900">
|
||||
Werden Sie Teil der uLoad Community
|
||||
</h2>
|
||||
<p class="mb-8 text-lg text-gray-600">
|
||||
Schließen Sie sich tausenden zufriedenen Nutzern an.
|
||||
</p>
|
||||
<a
|
||||
href="https://app.ulo.ad/register"
|
||||
class="inline-block rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
>
|
||||
Jetzt kostenlos starten →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
59
uload/apps/landing/src/pages/agb.astro
Normal file
59
uload/apps/landing/src/pages/agb.astro
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Allgemeine Geschäftsbedingungen" lastUpdated="Januar 2024">
|
||||
<h2>§ 1 Geltungsbereich</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>§ 2 Leistungsbeschreibung</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>§ 3 Registrierung und Nutzerkonto</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>§ 4 Nutzungsregeln</h2>
|
||||
<p>Der Nutzer verpflichtet sich, den Dienst nicht für rechtswidrige Zwecke zu nutzen. Insbesondere ist es untersagt:</p>
|
||||
<ul>
|
||||
<li>Links zu illegalen Inhalten zu erstellen</li>
|
||||
<li>Spam oder Phishing-Links zu verbreiten</li>
|
||||
<li>Die Dienste für automatisierte Massenanfragen zu missbrauchen</li>
|
||||
<li>Andere Nutzer zu belästigen oder zu täuschen</li>
|
||||
</ul>
|
||||
|
||||
<h2>§ 5 Preise und Zahlung</h2>
|
||||
<p>
|
||||
Die Nutzung der Basisfunktionen ist kostenlos. Für erweiterte Funktionen können kostenpflichtige Abonnements abgeschlossen werden. Alle Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.
|
||||
</p>
|
||||
|
||||
<h2>§ 6 Kündigung</h2>
|
||||
<p>
|
||||
Kostenlose Konten können jederzeit gelöscht werden. Kostenpflichtige Abonnements können zum Ende der jeweiligen Abrechnungsperiode gekündigt werden.
|
||||
</p>
|
||||
|
||||
<h2>§ 7 Haftung</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>§ 8 Datenschutz</h2>
|
||||
<p>
|
||||
Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung und den geltenden Datenschutzgesetzen.
|
||||
</p>
|
||||
|
||||
<h2>§ 9 Änderungen der AGB</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>§ 10 Schlussbestimmungen</h2>
|
||||
<p>
|
||||
Es gilt das Recht der Bundesrepublik Deutschland. Sollten einzelne Bestimmungen dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
83
uload/apps/landing/src/pages/blog/[slug].astro
Normal file
83
uload/apps/landing/src/pages/blog/[slug].astro
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection, 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);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={post.data.title} description={post.data.description}>
|
||||
<article class="px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Header -->
|
||||
<header class="mb-12">
|
||||
<a href="/blog" class="inline-flex items-center gap-2 text-sm text-primary-600 hover:underline mb-6">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück zum Blog
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl mb-4">
|
||||
{post.data.title}
|
||||
</h1>
|
||||
<div class="flex items-center gap-4 text-gray-500">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.map(tag => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-lg prose-gray max-w-none prose-headings:font-bold prose-a:text-primary-600 prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-16 pt-8 border-t border-gray-200">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<a href="/blog" class="text-primary-600 hover:underline">
|
||||
← Alle Artikel
|
||||
</a>
|
||||
<a
|
||||
href="https://app.ulo.ad/register"
|
||||
class="inline-block rounded-lg bg-primary-600 px-6 py-2 font-medium text-white transition hover:bg-primary-700"
|
||||
>
|
||||
Jetzt uLoad testen
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
68
uload/apps/landing/src/pages/blog/index.astro
Normal file
68
uload/apps/landing/src/pages/blog/index.astro
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
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);
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title="Blog" description="Tipps, Tricks und Best Practices rund um Link-Management, URL-Verkürzung und digitales Marketing.">
|
||||
<section class="px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Blog
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Tipps, Tricks und Best Practices rund um Link-Management und digitales Marketing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{posts.map(post => (
|
||||
<article class="group rounded-xl border border-gray-200 bg-white overflow-hidden transition hover:shadow-xl">
|
||||
<a href={`/blog/${post.slug}`} class="block">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||
<time datetime={post.data.pubDate.toISOString()}>
|
||||
{formatDate(post.data.pubDate)}
|
||||
</time>
|
||||
{post.data.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{post.data.author}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2 group-hover:text-primary-600 transition-colors">
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<p class="text-gray-600 line-clamp-3">
|
||||
{post.data.description}
|
||||
</p>
|
||||
{post.data.tags && (
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
{post.data.tags.slice(0, 3).map(tag => (
|
||||
<span class="inline-block rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-600">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
76
uload/apps/landing/src/pages/datenschutz.astro
Normal file
76
uload/apps/landing/src/pages/datenschutz.astro
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Datenschutzerklärung" lastUpdated="Januar 2024">
|
||||
<h2>1. Datenschutz auf einen Blick</h2>
|
||||
|
||||
<h3>Allgemeine Hinweise</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Datenerfassung auf dieser Website</h3>
|
||||
<p>
|
||||
<strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong><br />
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
|
||||
</p>
|
||||
|
||||
<h3>Wie erfassen wir Ihre Daten?</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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).
|
||||
</p>
|
||||
|
||||
<h2>2. Hosting</h2>
|
||||
<p>
|
||||
Wir hosten die Inhalte unserer Website bei folgendem Anbieter:
|
||||
</p>
|
||||
<p>
|
||||
Die Server befinden sich in Deutschland und unterliegen den strengen deutschen Datenschutzgesetzen.
|
||||
</p>
|
||||
|
||||
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
|
||||
|
||||
<h3>Datenschutz</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Hinweis zur verantwortlichen Stelle</h3>
|
||||
<p>
|
||||
Die verantwortliche Stelle für die Datenverarbeitung auf dieser Website ist im Impressum genannt.
|
||||
</p>
|
||||
|
||||
<h2>4. Datenerfassung auf dieser Website</h2>
|
||||
|
||||
<h3>Cookies</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Server-Log-Dateien</h3>
|
||||
<p>
|
||||
Der Provider der Seiten erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Browsertyp und Browserversion</li>
|
||||
<li>verwendetes Betriebssystem</li>
|
||||
<li>Referrer URL</li>
|
||||
<li>Hostname des zugreifenden Rechners</li>
|
||||
<li>Uhrzeit der Serveranfrage</li>
|
||||
<li>IP-Adresse (anonymisiert)</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Ihre Rechte</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>6. Kontakt</h2>
|
||||
<p>
|
||||
Bei Fragen zum Datenschutz können Sie sich jederzeit an uns wenden. Die Kontaktdaten finden Sie im Impressum.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
158
uload/apps/landing/src/pages/features.astro
Normal file
158
uload/apps/landing/src/pages/features.astro
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
---
|
||||
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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title="Features" description="Entdecken Sie alle Features von uLoad - URL-Verkürzung, Analytics, QR-Codes und Team-Kollaboration.">
|
||||
<!-- Hero -->
|
||||
<section class="bg-gradient-to-br from-primary-500/5 via-white to-purple-600/5 px-4 py-16 sm:px-6 lg:px-8 lg:py-24">
|
||||
<div class="mx-auto max-w-7xl text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
|
||||
Features die den Unterschied machen
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-lg text-gray-600">
|
||||
Von einfacher URL-Verkürzung bis hin zu detaillierten Analytics – uLoad bietet alles was Profis brauchen.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="rounded-lg bg-primary-600 px-8 py-3 font-semibold text-white shadow-lg transition hover:bg-primary-700"
|
||||
>
|
||||
Kostenlos starten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature Categories -->
|
||||
{featureCategories.map((category, idx) => (
|
||||
<section class:list={["px-4 py-16 sm:px-6 lg:px-8", idx % 2 === 1 ? "bg-gray-50" : "bg-white"]}>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900">
|
||||
{category.title}
|
||||
</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{category.features.map(feature => (
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 transition hover:shadow-lg">
|
||||
<div class="mb-4 text-4xl">{feature.icon}</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900">{feature.title}</h3>
|
||||
<p class="text-sm text-gray-600">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="bg-primary-600 px-4 py-16 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold text-white">
|
||||
Bereit loszulegen?
|
||||
</h2>
|
||||
<p class="mb-8 text-lg text-primary-100">
|
||||
Starten Sie kostenlos und entdecken Sie alle Features selbst.
|
||||
</p>
|
||||
<a
|
||||
href={`${appUrl}/register`}
|
||||
class="inline-block rounded-lg bg-white px-8 py-3 font-semibold text-primary-600 shadow-lg transition hover:bg-gray-100"
|
||||
>
|
||||
Jetzt kostenlos starten →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
55
uload/apps/landing/src/pages/impressum.astro
Normal file
55
uload/apps/landing/src/pages/impressum.astro
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Impressum">
|
||||
<h2>Angaben gemäß § 5 TMG</h2>
|
||||
|
||||
<p>
|
||||
<strong>uLoad</strong><br />
|
||||
[Ihr Name / Firmenname]<br />
|
||||
[Straße und Hausnummer]<br />
|
||||
[PLZ Ort]<br />
|
||||
Deutschland
|
||||
</p>
|
||||
|
||||
<h2>Kontakt</h2>
|
||||
<p>
|
||||
E-Mail: kontakt@ulo.ad
|
||||
</p>
|
||||
|
||||
<h2>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV</h2>
|
||||
<p>
|
||||
[Ihr Name]<br />
|
||||
[Adresse wie oben]
|
||||
</p>
|
||||
|
||||
<h2>EU-Streitschlichtung</h2>
|
||||
<p>
|
||||
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a>
|
||||
</p>
|
||||
<p>
|
||||
Unsere E-Mail-Adresse finden Sie oben im Impressum.
|
||||
</p>
|
||||
|
||||
<h2>Verbraucherstreitbeilegung / Universalschlichtungsstelle</h2>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Urheberrecht</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</LegalLayout>
|
||||
12
uload/apps/landing/src/pages/index.astro
Normal file
12
uload/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import HeroSection from '../components/HeroSection.astro';
|
||||
import FeaturesSection from '../components/FeaturesSection.astro';
|
||||
import PricingSection from '../components/PricingSection.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Intelligenter URL-Shortener">
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<PricingSection />
|
||||
</BaseLayout>
|
||||
196
uload/apps/landing/src/pages/sicherheit.astro
Normal file
196
uload/apps/landing/src/pages/sicherheit.astro
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
---
|
||||
import LegalLayout from '../layouts/LegalLayout.astro';
|
||||
---
|
||||
|
||||
<LegalLayout title="Sicherheit" lastUpdated="November 2024">
|
||||
<div class="rounded-lg bg-green-50 p-4 text-green-800 mb-8">
|
||||
<p class="font-semibold">Ihre Sicherheit ist unsere Priorität</p>
|
||||
<p class="mt-1">
|
||||
Bei uload setzen wir modernste Sicherheitsstandards ein, um Ihre Daten und Links zu schützen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Verschlüsselung</h2>
|
||||
|
||||
<h3>SSL/TLS-Verschlüsselung</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Verschlüsselte Speicherung</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3>Ende-zu-Ende Verschlüsselung für Premium-Nutzer</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>Authentifizierung & Zugriffskontrolle</h2>
|
||||
|
||||
<h3>Sichere Authentifizierung</h3>
|
||||
<ul>
|
||||
<li>Starke Passwort-Anforderungen (mindestens 8 Zeichen, Groß-/Kleinbuchstaben, Zahlen)</li>
|
||||
<li>Zwei-Faktor-Authentifizierung (2FA) verfügbar</li>
|
||||
<li>Automatische Sitzungsbeendigung nach Inaktivität</li>
|
||||
<li>Schutz vor Brute-Force-Angriffen durch Rate-Limiting</li>
|
||||
</ul>
|
||||
|
||||
<h3>Passwortgeschützte Links</h3>
|
||||
<p>
|
||||
Erstellen Sie passwortgeschützte Links für zusätzliche Sicherheit. Nur Personen mit dem korrekten Passwort können auf die Ziel-URL zugreifen.
|
||||
</p>
|
||||
|
||||
<h3>IP-Whitelisting für Enterprise</h3>
|
||||
<p>
|
||||
Enterprise-Kunden können IP-Whitelisting aktivieren, um den Zugriff auf ihre Links nur von bestimmten IP-Adressen oder IP-Bereichen zu erlauben.
|
||||
</p>
|
||||
|
||||
<h2>Infrastruktur-Sicherheit</h2>
|
||||
|
||||
<h3>Hosting & Server</h3>
|
||||
<ul>
|
||||
<li>Hosting in ISO 27001 zertifizierten Rechenzentren</li>
|
||||
<li>Redundante Server-Architektur für maximale Verfügbarkeit</li>
|
||||
<li>Regelmäßige Sicherheitsupdates und Patches</li>
|
||||
<li>24/7 Überwachung der Systemintegrität</li>
|
||||
</ul>
|
||||
|
||||
<h3>DDoS-Schutz</h3>
|
||||
<p>
|
||||
Unser Service ist durch einen fortschrittlichen DDoS-Schutz abgesichert, der Angriffe automatisch erkennt und abwehrt, um die Verfügbarkeit unseres Dienstes zu gewährleisten.
|
||||
</p>
|
||||
|
||||
<h3>Web Application Firewall (WAF)</h3>
|
||||
<p>
|
||||
Eine Web Application Firewall schützt vor gängigen Web-Angriffen wie SQL-Injection, Cross-Site-Scripting (XSS) und anderen OWASP Top 10 Bedrohungen.
|
||||
</p>
|
||||
|
||||
<h2>Überwachung & Schutz</h2>
|
||||
|
||||
<h3>Malware & Phishing-Schutz</h3>
|
||||
<p>
|
||||
Alle erstellten Links werden automatisch gegen bekannte Malware- und Phishing-Datenbanken geprüft. Verdächtige Links werden blockiert und zur manuellen Überprüfung markiert.
|
||||
</p>
|
||||
|
||||
<h3>Echtzeit-Überwachung</h3>
|
||||
<ul>
|
||||
<li>Kontinuierliche Überwachung auf verdächtige Aktivitäten</li>
|
||||
<li>Automatische Erkennung von Missbrauchsmustern</li>
|
||||
<li>Sofortige Benachrichtigung bei Sicherheitsvorfällen</li>
|
||||
<li>Detaillierte Audit-Logs für Enterprise-Kunden</li>
|
||||
</ul>
|
||||
|
||||
<h3>Link-Validierung</h3>
|
||||
<p>
|
||||
Regelmäßige Überprüfung aller Ziel-URLs auf Verfügbarkeit und Sicherheit. Gefährliche oder kompromittierte Websites werden automatisch blockiert.
|
||||
</p>
|
||||
|
||||
<h2>Datenschutz & Compliance</h2>
|
||||
|
||||
<h3>DSGVO-Konformität</h3>
|
||||
<p>
|
||||
Vollständige Einhaltung der Datenschutz-Grundverordnung (DSGVO). Sie haben jederzeit die volle Kontrolle über Ihre Daten mit Rechten auf Auskunft, Berichtigung und Löschung.
|
||||
</p>
|
||||
|
||||
<h3>Datensparsamkeit</h3>
|
||||
<p>
|
||||
Wir sammeln nur die minimal notwendigen Daten für den Betrieb unseres Services. Keine unnötige Datensammlung oder -weitergabe an Dritte.
|
||||
</p>
|
||||
|
||||
<h3>Regelmäßige Audits</h3>
|
||||
<p>
|
||||
Unabhängige Sicherheitsaudits und Penetrationstests werden regelmäßig durchgeführt, um höchste Sicherheitsstandards zu gewährleisten.
|
||||
</p>
|
||||
|
||||
<h2>Backup & Wiederherstellung</h2>
|
||||
|
||||
<h3>Automatische Backups</h3>
|
||||
<ul>
|
||||
<li>Tägliche automatische Backups aller Daten</li>
|
||||
<li>Geografisch verteilte Backup-Speicherung</li>
|
||||
<li>Verschlüsselte Backup-Archive</li>
|
||||
<li>Regelmäßige Wiederherstellungstests</li>
|
||||
</ul>
|
||||
|
||||
<h3>Disaster Recovery</h3>
|
||||
<p>
|
||||
Umfassender Disaster-Recovery-Plan mit RPO (Recovery Point Objective) von maximal 24 Stunden und RTO (Recovery Time Objective) von maximal 4 Stunden.
|
||||
</p>
|
||||
|
||||
<h2>Ihre Verantwortung</h2>
|
||||
|
||||
<h3>Best Practices für Nutzer</h3>
|
||||
<ul>
|
||||
<li>Verwenden Sie starke, einzigartige Passwörter</li>
|
||||
<li>Aktivieren Sie die Zwei-Faktor-Authentifizierung</li>
|
||||
<li>Teilen Sie Ihre Zugangsdaten niemals mit anderen</li>
|
||||
<li>Melden Sie verdächtige Aktivitäten sofort</li>
|
||||
<li>Halten Sie Ihre Kontaktinformationen aktuell</li>
|
||||
<li>Überprüfen Sie regelmäßig Ihre Account-Aktivitäten</li>
|
||||
</ul>
|
||||
|
||||
<h2>Sicherheitsvorfälle melden</h2>
|
||||
|
||||
<h3>Verantwortungsvolle Offenlegung</h3>
|
||||
<p>
|
||||
Wir schätzen die Arbeit von Sicherheitsforschern. Wenn Sie eine Sicherheitslücke entdecken, melden Sie diese bitte verantwortungsvoll an:
|
||||
</p>
|
||||
<p class="font-mono bg-gray-100 p-3 rounded-lg mt-2">
|
||||
security@uload.de
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Bitte geben Sie uns angemessene Zeit zur Behebung, bevor Sie die Schwachstelle öffentlich machen.
|
||||
</p>
|
||||
|
||||
<h3>Bug Bounty Programm</h3>
|
||||
<p>
|
||||
Für kritische Sicherheitslücken bieten wir Belohnungen im Rahmen unseres Bug Bounty Programms.
|
||||
</p>
|
||||
|
||||
<h2>Zertifizierungen & Standards</h2>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 not-prose mt-4">
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">ISO 27001</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Informationssicherheits-Management-System zertifiziert
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">SSL Labs A+</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Höchste Bewertung für SSL/TLS-Konfiguration
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">OWASP Compliance</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Einhaltung der OWASP-Sicherheitsrichtlinien
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="font-semibold text-gray-900 mb-2">PCI DSS Ready</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
Bereit für Payment Card Industry Standards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8">Kontakt</h2>
|
||||
<p>
|
||||
Bei Fragen zur Sicherheit unseres Services kontaktieren Sie uns:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>E-Mail:</strong> security@uload.de</li>
|
||||
<li><strong>PGP-Schlüssel:</strong> Verfügbar auf Anfrage</li>
|
||||
</ul>
|
||||
|
||||
<div class="rounded-lg bg-blue-50 p-4 text-blue-800 mt-8 not-prose">
|
||||
<p class="font-semibold">Tipp:</p>
|
||||
<p>
|
||||
Aktivieren Sie die Zwei-Faktor-Authentifizierung in Ihren Account-Einstellungen für maximale Sicherheit!
|
||||
</p>
|
||||
</div>
|
||||
</LegalLayout>
|
||||
49
uload/apps/landing/src/styles/global.css
Normal file
49
uload/apps/landing/src/styles/global.css
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-dark: #2563eb;
|
||||
--color-background: #ffffff;
|
||||
--color-background-secondary: #f9fafb;
|
||||
--color-text: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-border: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: #111827;
|
||||
--color-background-secondary: #1f2937;
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-border: #374151;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 text-base font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors 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;
|
||||
}
|
||||
}
|
||||
27
uload/apps/landing/tailwind.config.mjs
Normal file
27
uload/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
11
uload/apps/landing/tsconfig.json
Normal file
11
uload/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,8 @@
|
|||
"zod": "^4.0.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.934.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.934.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
|
|
|
|||
1
uload/apps/web/project.inlang/.gitignore
vendored
1
uload/apps/web/project.inlang/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
cache
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"nav_login": "Login",
|
||||
"nav_register": "Register",
|
||||
"nav_dashboard": "Dashboard",
|
||||
"nav_folders": "Folders",
|
||||
"nav_profile": "Profile",
|
||||
"nav_logout": "Logout",
|
||||
"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",
|
||||
"auth_modal_signin": "Sign in",
|
||||
"home_guest_signin_hint": "to access advanced features",
|
||||
"home_processing": "Processing...",
|
||||
"home_submit_button_qr": "Generate QR Code",
|
||||
"home_submit_button": "Create Link"
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
vBR0K1t5zNgjHxICus
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"sourceLanguageTag": "en",
|
||||
"languageTags": ["en", "de", "es", "fr", "it"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-json@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.json": {
|
||||
"pathPattern": "./messages/{languageTag}.json"
|
||||
}
|
||||
}
|
||||
7
uload/apps/web/src/app.d.ts
vendored
7
uload/apps/web/src/app.d.ts
vendored
|
|
@ -1,8 +1,9 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { DB } from '$lib/db';
|
||||
import type { AvailableLanguageTag } from '$paraglide/runtime';
|
||||
import type { ParaglideLocals } from '@inlang/paraglide-sveltekit';
|
||||
|
||||
// Supported locales
|
||||
export type SupportedLocale = 'en' | 'de' | 'es' | 'fr' | 'it';
|
||||
|
||||
// User type (will be replaced by external auth later)
|
||||
export interface User {
|
||||
|
|
@ -20,7 +21,7 @@ declare global {
|
|||
interface Locals {
|
||||
db: DB;
|
||||
user: User | null;
|
||||
paraglide: ParaglideLocals<AvailableLanguageTag>;
|
||||
locale: SupportedLocale;
|
||||
}
|
||||
interface PageData {
|
||||
user: User | null;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
getFreeText,
|
||||
type VariantContent
|
||||
} from '../config/variants';
|
||||
import { getLocale } from '$paraglide/runtime.js';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { PageData, ActionData } from '../../../routes/$types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -42,14 +43,14 @@
|
|||
// Log for debugging
|
||||
if (showDebug) {
|
||||
console.log('A/B Test Variant:', variant, content);
|
||||
console.log('Current Locale:', getLocale());
|
||||
console.log('Current Locale:', get(locale));
|
||||
}
|
||||
});
|
||||
|
||||
// React to locale changes - use derived state
|
||||
$effect(() => {
|
||||
// This will re-run when locale changes
|
||||
const currentLocale = getLocale();
|
||||
const currentLocale = get(locale);
|
||||
|
||||
// Update content based on current locale
|
||||
content = getVariantContent(variant);
|
||||
|
|
@ -82,7 +83,7 @@
|
|||
<div class="font-bold text-green-400">A/B Test Debug</div>
|
||||
<div>Variant: <span class="text-yellow-400">{variant}</span></div>
|
||||
<div>Name: {content.name}</div>
|
||||
<div>Locale: <span class="text-blue-400">{getLocale()}</span></div>
|
||||
<div>Locale: <span class="text-blue-400">{get(locale)}</span></div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { setLocale, getLocale } from '$paraglide/runtime.js';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import '$lib/i18n';
|
||||
|
||||
let showDropdown = $state(false);
|
||||
|
||||
|
|
@ -14,7 +16,7 @@
|
|||
// Get current language on mount
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const currentCode = getLocale();
|
||||
const currentCode = get(locale) || 'en';
|
||||
currentLanguage = languages.find((lang) => lang.code === currentCode) || languages[0];
|
||||
}
|
||||
});
|
||||
|
|
@ -23,8 +25,8 @@
|
|||
if (browser) {
|
||||
// Save preference
|
||||
localStorage.setItem('preferred-language', langCode);
|
||||
// Update Paraglide locale
|
||||
setLocale(langCode as any);
|
||||
// Update svelte-i18n locale
|
||||
locale.set(langCode);
|
||||
// Update current language display
|
||||
currentLanguage = languages.find((lang) => lang.code === langCode) || languages[0];
|
||||
// Close dropdown
|
||||
|
|
|
|||
15
uload/apps/web/src/routes/(auth)/+layout.svelte
Normal file
15
uload/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { data, children }: { data: any; children: Snippet } = $props();
|
||||
|
||||
$effect(() => {
|
||||
// Redirect to dashboard if already logged in
|
||||
if (data.user) {
|
||||
goto('/my');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { UloadLogo } from '@manacore/shared-branding';
|
||||
import { pb } from '$lib/pocketbase';
|
||||
|
||||
async function handleForgotPassword(email: string) {
|
||||
try {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
// PocketBase doesn't reveal if email exists for security
|
||||
// So we always show success message
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="uLoad"
|
||||
logo={UloadLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onForgotPassword={handleForgotPassword}
|
||||
goto={goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#f8fafc"
|
||||
darkBackground="#0f172a"
|
||||
translations={{
|
||||
titleForm: 'Passwort zurücksetzen',
|
||||
titleSuccess: 'E-Mail gesendet',
|
||||
description: 'Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
sendResetLinkButton: 'Link senden',
|
||||
sending: 'Wird gesendet...',
|
||||
backToLogin: 'Zurück zum Login',
|
||||
resendEmail: 'E-Mail erneut senden',
|
||||
successMessage: 'Wir haben einen Link zum Zurücksetzen deines Passworts an {email} gesendet. Bitte überprüfe deinen Posteingang.',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
sendFailed: 'Senden der E-Mail fehlgeschlagen'
|
||||
}}
|
||||
/>
|
||||
62
uload/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
62
uload/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { UloadLogo } from '@manacore/shared-branding';
|
||||
import { pb } from '$lib/pocketbase';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
// Invalidate all data to refresh server-side auth state
|
||||
await invalidateAll();
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: err?.message || 'Ungültige E-Mail oder Passwort'
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="uLoad"
|
||||
logo={UloadLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onSignIn={handleSignIn}
|
||||
goto={goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/my"
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#f8fafc"
|
||||
darkBackground="#0f172a"
|
||||
translations={{
|
||||
title: 'Anmelden',
|
||||
subtitle: 'Melde dich mit deinem uLoad Account an',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
passwordPlaceholder: 'Passwort',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
signInButton: 'Anmelden',
|
||||
signingIn: 'Wird angemeldet...',
|
||||
success: 'Erfolg!',
|
||||
orDivider: 'oder',
|
||||
noAccount: 'Noch kein Account?',
|
||||
createAccount: 'Jetzt registrieren',
|
||||
skipToForm: 'Zum Login-Formular springen',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
passwordRequired: 'Passwort ist erforderlich',
|
||||
signInFailed: 'Anmeldung fehlgeschlagen',
|
||||
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
|
||||
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
|
||||
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...'
|
||||
}}
|
||||
/>
|
||||
87
uload/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
87
uload/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { UloadLogo } from '@manacore/shared-branding';
|
||||
import { pb } from '$lib/pocketbase';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
try {
|
||||
// Create user
|
||||
await pb.collection('users').create({
|
||||
email: email.toLowerCase().trim(),
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true
|
||||
});
|
||||
|
||||
// Request verification email
|
||||
try {
|
||||
await pb.collection('users').requestVerification(email);
|
||||
} catch (emailErr) {
|
||||
console.error('Failed to send verification email:', emailErr);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true
|
||||
};
|
||||
} catch (err: any) {
|
||||
const errorData = err?.response?.data || err?.data || {};
|
||||
|
||||
if (errorData.email?.message?.includes('unique')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Diese E-Mail ist bereits registriert. Bitte melde dich an.'
|
||||
};
|
||||
}
|
||||
|
||||
if (errorData.email?.message) {
|
||||
return { success: false, error: errorData.email.message };
|
||||
}
|
||||
|
||||
if (errorData.password?.message) {
|
||||
return { success: false, error: errorData.password.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err?.message || 'Registrierung fehlgeschlagen. Bitte versuche es erneut.'
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<RegisterPage
|
||||
appName="uLoad"
|
||||
logo={UloadLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onSignUp={handleSignUp}
|
||||
goto={goto}
|
||||
successRedirect="/login?registered=true"
|
||||
loginPath="/login"
|
||||
lightBackground="#f8fafc"
|
||||
darkBackground="#0f172a"
|
||||
translations={{
|
||||
title: 'Account erstellen',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
passwordPlaceholder: 'Passwort',
|
||||
confirmPasswordPlaceholder: 'Passwort bestätigen',
|
||||
passwordRequirements: 'Passwort muss mindestens 8 Zeichen mit Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten.',
|
||||
createAccountButton: 'Account erstellen',
|
||||
creatingAccount: 'Wird erstellt...',
|
||||
backToLogin: 'Zurück zum Login',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
passwordRequired: 'Passwort ist erforderlich',
|
||||
confirmPasswordRequired: 'Bitte bestätige dein Passwort',
|
||||
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
|
||||
passwordTooShort: 'Passwort muss mindestens 8 Zeichen haben',
|
||||
passwordStrengthError: 'Passwort muss Kleinbuchstaben, Großbuchstaben, Zahl und Sonderzeichen enthalten',
|
||||
registrationFailed: 'Registrierung fehlgeschlagen',
|
||||
accountCreated: 'Account erstellt! Bitte überprüfe deine E-Mail zur Verifizierung.'
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: '🔗',
|
||||
title: 'URL-Verkürzung',
|
||||
description: 'Verwandeln Sie lange URLs in kurze, teilbare Links mit nur einem Klick.'
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Detaillierte Analytics',
|
||||
description: 'Verfolgen Sie Klicks, Herkunft und Engagement Ihrer Links in Echtzeit.'
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
title: 'Digitale Visitenkarten',
|
||||
description: 'Erstellen Sie professionelle digitale Visitenkarten mit QR-Codes.'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Anpassbare Templates',
|
||||
description: 'Nutzen Sie vorgefertigte Templates oder erstellen Sie eigene Designs.'
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Passwortschutz',
|
||||
description: 'Schützen Sie Ihre Links mit Passwörtern und Ablaufdaten.'
|
||||
},
|
||||
{
|
||||
icon: '🏷️',
|
||||
title: 'Tag-System',
|
||||
description: 'Organisieren Sie Ihre Links mit Tags für bessere Übersicht.'
|
||||
}
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '10K+', label: 'Aktive Nutzer' },
|
||||
{ value: '500K+', label: 'Erstellte Links' },
|
||||
{ value: '2M+', label: 'Klicks verfolgt' },
|
||||
{ value: '99.9%', label: 'Uptime' }
|
||||
];
|
||||
|
||||
const team = [
|
||||
{
|
||||
name: 'Till Schneider',
|
||||
role: 'Gründer & Entwickler',
|
||||
description: 'Full-Stack Entwickler mit Leidenschaft für saubere, effiziente Lösungen.',
|
||||
avatar: '👨💻'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title || 'Über Uload'}</title>
|
||||
<meta name="description" content={data.description || 'Erfahren Sie mehr über Uload'} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-theme-background">
|
||||
<Navigation user={data.user} />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative overflow-hidden border-b border-theme-border bg-theme-surface">
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div class="relative mx-auto max-w-7xl px-4 py-24 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold tracking-tight text-theme-text sm:text-5xl md:text-6xl">
|
||||
Über <span class="text-theme-primary">Uload</span>
|
||||
</h1>
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg text-theme-text-muted">
|
||||
Ihre moderne Plattform für professionelles Link-Management und digitale Präsenz
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mission Section -->
|
||||
<section class="bg-theme-background py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-3xl font-bold text-theme-text">Unsere Mission</h2>
|
||||
<p class="mt-6 text-lg leading-relaxed text-theme-text-muted">
|
||||
Bei Uload glauben wir daran, dass Link-Management einfach, effizient und zugänglich sein sollte.
|
||||
Unsere Plattform wurde entwickelt, um Unternehmen und Einzelpersonen dabei zu helfen,
|
||||
ihre Online-Präsenz zu optimieren und wertvolle Einblicke in ihr Publikum zu gewinnen.
|
||||
</p>
|
||||
<p class="mt-4 text-lg leading-relaxed text-theme-text-muted">
|
||||
Von der einfachen URL-Verkürzung bis hin zu erweiterten Analytics und digitalen Visitenkarten -
|
||||
wir bieten alle Tools, die Sie für erfolgreiches digitales Marketing benötigen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Grid -->
|
||||
<section class="bg-theme-surface py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-theme-text">Was macht uns besonders?</h2>
|
||||
<p class="mt-4 text-lg text-theme-text-muted">
|
||||
Entdecken Sie die Features, die Uload zur ersten Wahl für Link-Management machen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each features as feature}
|
||||
<div class="group rounded-lg border border-theme-border bg-theme-surface-hover p-6 transition-all hover:shadow-md hover:scale-105">
|
||||
<div class="mb-4 text-4xl">{feature.icon}</div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-theme-text">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="text-theme-text-muted">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="bg-theme-primary py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Uload in Zahlen</h2>
|
||||
<p class="mt-4 text-lg text-gray-200">
|
||||
Vertrauen Sie auf eine bewährte Plattform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
{#each stats as stat}
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-white">{stat.value}</div>
|
||||
<div class="mt-2 text-sm text-gray-200">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Team Section -->
|
||||
<section class="bg-theme-background py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-theme-text">Das Team</h2>
|
||||
<p class="mt-4 text-lg text-theme-text-muted">
|
||||
Die Menschen hinter Uload
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 flex justify-center">
|
||||
{#each team as member}
|
||||
<div class="max-w-sm rounded-lg border border-theme-border bg-theme-surface p-8 text-center shadow-lg">
|
||||
<div class="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-theme-surface-hover text-5xl">
|
||||
{member.avatar}
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-theme-text">{member.name}</h3>
|
||||
<p class="mt-1 text-sm text-theme-accent">{member.role}</p>
|
||||
<p class="mt-4 text-theme-text-muted">{member.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Values Section -->
|
||||
<section class="bg-theme-surface py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-theme-text">Unsere Werte</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid gap-8 md:grid-cols-3">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-theme-surface-hover border border-theme-border text-2xl">
|
||||
🚀
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-theme-text">Innovation</h3>
|
||||
<p class="text-theme-text-muted">
|
||||
Wir entwickeln ständig neue Features und verbessern bestehende Funktionen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-theme-surface-hover border border-theme-border text-2xl">
|
||||
🛡️
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-theme-text">Sicherheit</h3>
|
||||
<p class="text-theme-text-muted">
|
||||
Ihre Daten sind bei uns sicher. Datenschutz und Sicherheit haben höchste Priorität.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-theme-surface-hover border border-theme-border text-2xl">
|
||||
💡
|
||||
</div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-theme-text">Einfachheit</h3>
|
||||
<p class="text-theme-text-muted">
|
||||
Komplexe Funktionen, einfach zu bedienen. Das ist unser Versprechen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="bg-theme-background py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="rounded-2xl bg-theme-primary px-8 py-12 text-center shadow-xl">
|
||||
<h2 class="text-3xl font-bold text-white">Bereit loszulegen?</h2>
|
||||
<p class="mt-4 text-lg text-gray-200">
|
||||
Erstellen Sie noch heute Ihr kostenloses Konto und entdecken Sie die Möglichkeiten
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href="/register"
|
||||
class="rounded-lg bg-white px-8 py-3 font-semibold text-theme-primary transition hover:bg-gray-50 hover:scale-105"
|
||||
>
|
||||
Kostenlos starten
|
||||
</a>
|
||||
<a
|
||||
href="/features"
|
||||
class="rounded-lg border-2 border-white px-8 py-3 font-semibold text-white transition hover:bg-white/10 hover:scale-105"
|
||||
>
|
||||
Features entdecken
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<section class="bg-theme-surface py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="text-3xl font-bold text-theme-text">Kontakt</h2>
|
||||
<p class="mt-4 text-lg text-theme-text-muted">
|
||||
Haben Sie Fragen oder Feedback? Wir freuen uns von Ihnen zu hören!
|
||||
</p>
|
||||
<div class="mt-8 flex flex-col items-center gap-4">
|
||||
<a
|
||||
href="mailto:support@ulo.ad"
|
||||
class="flex items-center gap-2 text-theme-accent hover:text-theme-accent-hover transition-colors"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
support@ulo.ad
|
||||
</a>
|
||||
<a
|
||||
href="/features"
|
||||
class="flex items-center gap-2 text-theme-accent hover:text-theme-accent-hover transition-colors"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
Feature-Wünsche einreichen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-grid-pattern {
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%239C92AC' fill-opacity='0.1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
return {
|
||||
title: 'Über Uload - Moderne URL-Verkürzung & Link-Management',
|
||||
description: 'Erfahren Sie mehr über Uload - Ihre Plattform für professionelles Link-Management, URL-Verkürzung und digitale Visitenkarten.'
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue