This commit is contained in:
Till-JS 2025-12-03 11:57:30 +01:00
commit ba746fce04
92 changed files with 6668 additions and 823 deletions

127
.claude/GUIDELINES.md Normal file
View file

@ -0,0 +1,127 @@
# Claude Code Guidelines
This directory contains comprehensive guidelines for working in the Mana Universe monorepo. These documents are designed to help Claude Code (and developers) maintain consistency across all projects.
## Quick Reference
| Document | Purpose |
|----------|---------|
| [Code Style](./guidelines/code-style.md) | Formatting, naming conventions, linting rules |
| [Database](./guidelines/database.md) | Drizzle ORM patterns, schema design, migrations |
| [Testing](./guidelines/testing.md) | Jest/Vitest patterns, mock factories, coverage |
| [NestJS Backend](./guidelines/nestjs-backend.md) | Controllers, services, DTOs, modules |
| [Error Handling](./guidelines/error-handling.md) | Go-style errors, error codes, Result types |
| [SvelteKit Web](./guidelines/sveltekit-web.md) | Svelte 5 runes, stores, routing |
| [Expo Mobile](./guidelines/expo-mobile.md) | React Native, NativeWind, navigation |
| [Authentication](./guidelines/authentication.md) | Mana Core Auth integration |
## Core Principles
### 1. Explicit Over Implicit
- Use Go-style error handling with explicit `Result<T>` returns
- Prefer named exports over default exports
- Use explicit types instead of `any`
### 2. Consistency Over Preference
- Follow existing patterns in the codebase
- Use shared packages for common functionality
- Maintain consistent naming across all projects
### 3. Simplicity Over Cleverness
- Don't over-engineer solutions
- Avoid premature abstractions
- Keep files focused and small
### 4. Safety First
- Always validate user input
- Use parameterized queries (Drizzle handles this)
- Never expose sensitive data in responses
## Technology Stack Summary
| Layer | Technology | Notes |
|-------|------------|-------|
| **Package Manager** | pnpm 9.15+ | Workspace monorepo |
| **Build System** | Turborepo | Parallel task execution |
| **Backend** | NestJS 10-11 | TypeScript, Drizzle ORM |
| **Web** | SvelteKit 2 + Svelte 5 | Runes mode only |
| **Mobile** | Expo SDK 52-54 | React Native, NativeWind |
| **Database** | PostgreSQL | Via Drizzle ORM |
| **Auth** | Mana Core Auth | Better Auth, EdDSA JWT |
| **Storage** | S3-compatible | MinIO (dev), Hetzner (prod) |
## Project Structure
```
manacore-monorepo/
├── .claude/
│ ├── GUIDELINES.md # This file
│ ├── guidelines/ # Detailed guidelines
│ └── templates/ # Code templates
├── apps/ # Product applications
│ └── {project}/
│ ├── apps/
│ │ ├── backend/ # NestJS API
│ │ ├── web/ # SvelteKit web
│ │ ├── mobile/ # Expo app
│ │ └── landing/ # Astro landing
│ └── packages/ # Project-specific shared
├── packages/ # Monorepo-wide shared
│ ├── shared-errors/ # Error codes & Result types
│ ├── shared-nestjs-auth/ # NestJS auth guards
│ ├── shared-auth/ # Client auth service
│ └── ...
├── services/ # Standalone microservices
│ └── mana-core-auth/ # Central auth service
└── CLAUDE.md # Root project overview
```
## Before Making Changes
1. **Read the relevant guideline** for the area you're working in
2. **Check existing patterns** in similar files
3. **Use shared packages** when available
4. **Follow the error handling pattern** with Result types
5. **Write tests** for new functionality
## Error Handling Philosophy
We use **Go-style error handling** across the entire stack:
```typescript
// Backend services return Result<T>
const result = await userService.findById(id);
if (!result.ok) {
// Handle error with error code
throw new AppException(result.error);
}
return result.data;
// Frontend handles errors explicitly
const { data, error } = await api.getUser(id);
if (error) {
showToast(error.message);
return;
}
```
See [Error Handling](./guidelines/error-handling.md) for complete details.
## Quick Commands
```bash
# Development
pnpm install # Install dependencies
pnpm {project}:dev # Start project (all apps)
pnpm dev:{project}:backend # Start just backend
pnpm dev:{project}:web # Start just web
# Quality
pnpm type-check # TypeScript validation
pnpm format # Format code
pnpm test # Run tests
# Database
pnpm {project}:db:push # Push schema changes
pnpm {project}:db:studio # Open Drizzle Studio
```

View file

@ -0,0 +1,597 @@
# Authentication Guidelines
## Overview
All authentication is handled by **Mana Core Auth**, a centralized authentication service using **Better Auth** with **EdDSA JWT** tokens.
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Web/Mobile │────>│ Backend API │────>│ mana-core-auth │
│ Client │ │ (NestJS) │ │ (port 3001) │
└─────────────────┘ └─────────────────┘ └──────────────────┘
│ │ │
│ 1. Login │ │
│─────────────────────────────────────────────>│
│ │ │
│ 2. JWT Token │ │
<─────────────────────────────────────────────│
│ │ │
│ 3. API Request │ │
│ + Bearer Token │ │
│──────────────────────>│ │
│ │ │
│ │ 4. Validate Token │
│ │──────────────────────>│
│ │ │
│ │ 5. {valid, payload} │
│ │<──────────────────────│
│ │ │
│ 6. Response │ │
<──────────────────────│ │
```
## Token Structure (EdDSA JWT)
```json
{
"sub": "user-uuid-123",
"email": "user@example.com",
"role": "user",
"sid": "session-id-456",
"iat": 1701234567,
"exp": 1701238167,
"iss": "manacore",
"aud": "manacore"
}
```
**Important**: Keep claims minimal. Do NOT include:
- Credit balance (changes frequently)
- Organization data (use API instead)
- Feature flags
- Other dynamic data
## Shared Packages
| Package | Purpose | Use Case |
|---------|---------|----------|
| `@manacore/shared-nestjs-auth` | NestJS guards/decorators | Backend APIs |
| `@mana-core/nestjs-integration` | Auth + Credits integration | Backends with credits |
| `@manacore/shared-auth` | Client auth service | Web/Mobile apps |
## Backend Integration
### Option 1: Simple Auth Only
Use `@manacore/shared-nestjs-auth` for JWT validation:
```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// No auth module needed - guards handle it
],
})
export class AppModule {}
```
```typescript
// file.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
@Controller('files')
@UseGuards(JwtAuthGuard) // Apply to all routes
export class FileController {
@Get()
async listFiles(@CurrentUser() user: CurrentUserData) {
// user.userId, user.email, user.role available
return this.fileService.findAll(user.userId);
}
}
```
### Option 2: Auth + Credits
Use `@mana-core/nestjs-integration` for full integration:
```typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ManaCoreModule } from '@mana-core/nestjs-integration';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
appId: config.get('APP_ID'),
serviceKey: config.get('MANA_CORE_SERVICE_KEY'),
debug: config.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
```
```typescript
// generation.controller.ts
import { Controller, Post, UseGuards, Body } from '@nestjs/common';
import { AuthGuard } from '@mana-core/nestjs-integration/guards';
import { CurrentUser } from '@mana-core/nestjs-integration/decorators';
import { CreditClientService } from '@mana-core/nestjs-integration';
@Controller('generations')
@UseGuards(AuthGuard)
export class GenerationController {
constructor(private creditClient: CreditClientService) {}
@Post()
async generate(@CurrentUser() user: any, @Body() dto: GenerateDto) {
// Check and consume credits
const result = await this.creditClient.consumeCredits(
user.sub,
'ai_generation',
10,
'AI image generation'
);
if (!result.ok) {
throw new AppException(result.error);
}
// Proceed with generation
return this.generationService.generate(user.sub, dto);
}
}
```
## Environment Variables
```env
# Required for all backends
MANA_CORE_AUTH_URL=http://localhost:3001
# Development bypass (optional)
NODE_ENV=development
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
# For credit operations (when using nestjs-integration)
MANA_CORE_SERVICE_KEY=your-service-key
APP_ID=your-app-id
```
## Client Integration (Web)
### Setup
```typescript
// src/lib/stores/auth.svelte.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { initializeWebAuth } from '@manacore/shared-auth';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Lazy initialize to avoid SSR issues
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: AUTH_URL });
_authService = auth.authService;
}
return _authService;
}
// State
let user = $state<User | null>(null);
let token = $state<string | null>(null);
let loading = $state(true);
// Initialize on app start
async function initialize() {
if (!browser) return;
const authService = getAuthService();
if (!authService) return;
const currentUser = await authService.getCurrentUser();
if (currentUser) {
user = currentUser;
token = await authService.getAccessToken();
}
loading = false;
}
// Actions
async function login(email: string, password: string): Promise<boolean> {
const authService = getAuthService();
if (!authService) return false;
try {
const result = await authService.signIn({ email, password });
user = result.user;
token = result.accessToken;
return true;
} catch {
return false;
}
}
async function logout() {
const authService = getAuthService();
if (authService) {
await authService.signOut();
}
user = null;
token = null;
goto('/login');
}
export const authStore = {
get user() { return user; },
get token() { return token; },
get loading() { return loading; },
get isAuthenticated() { return !!token; },
initialize,
login,
logout,
};
```
### Protected Routes
```svelte
<!-- src/routes/(protected)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
let { children } = $props();
onMount(() => {
authStore.initialize();
});
$effect(() => {
if (browser && !authStore.loading && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.loading}
<LoadingScreen />
{:else if authStore.isAuthenticated}
{@render children()}
{/if}
```
### API Requests with Token
```typescript
// src/lib/api/client.ts
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
const token = authStore.token;
const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
// Handle 401 - session expired
if (response.status === 401) {
authStore.logout();
goto('/login');
return { ok: false, error: { code: 'ERR_2000', message: 'Session expired' } };
}
const json = await response.json();
return json.ok ? { ok: true, data: json.data } : { ok: false, error: json.error };
}
```
## Client Integration (Mobile)
### Auth Provider
```tsx
// context/AuthProvider.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
import { initializeMobileAuth } from '@manacore/shared-auth';
import Constants from 'expo-constants';
const AUTH_URL = Constants.expoConfig?.extra?.authUrl ?? 'http://localhost:3001';
const TOKEN_KEY = 'mana_auth_token';
const USER_KEY = 'mana_auth_user';
interface AuthContextType {
user: User | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStoredAuth();
}, []);
async function loadStoredAuth() {
try {
const storedToken = await SecureStore.getItemAsync(TOKEN_KEY);
const storedUser = await SecureStore.getItemAsync(USER_KEY);
if (storedToken && storedUser) {
// Validate token is still valid
const isValid = await validateToken(storedToken);
if (isValid) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} else {
// Token expired, clear storage
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
}
}
} finally {
setLoading(false);
}
}
async function validateToken(token: string): Promise<boolean> {
try {
const response = await fetch(`${AUTH_URL}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const result = await response.json();
return result.valid === true;
} catch {
return false;
}
}
async function login(email: string, password: string): Promise<boolean> {
try {
const response = await fetch(`${AUTH_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const result = await response.json();
if (result.accessToken && result.user) {
await SecureStore.setItemAsync(TOKEN_KEY, result.accessToken);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user));
setToken(result.accessToken);
setUser(result.user);
return true;
}
return false;
} catch {
return false;
}
}
async function logout() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
setToken(null);
setUser(null);
}
return (
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be within AuthProvider');
return context;
}
```
## Auth Endpoints
### Mana Core Auth API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/register` | POST | Register new user |
| `/api/v1/auth/login` | POST | Login, returns JWT |
| `/api/v1/auth/logout` | POST | Logout, invalidates session |
| `/api/v1/auth/validate` | POST | Validate JWT token |
| `/api/v1/auth/refresh` | POST | Refresh access token |
| `/api/v1/auth/me` | GET | Get current user |
| `/api/v1/auth/jwks` | GET | Get JWKS for token verification |
### Request/Response Examples
**Register**
```bash
POST /api/v1/auth/register
{
"email": "user@example.com",
"password": "securepassword123",
"name": "John Doe"
}
Response:
{
"user": { "id": "...", "email": "...", "name": "..." },
"accessToken": "eyJ...",
"refreshToken": "..."
}
```
**Login**
```bash
POST /api/v1/auth/login
{
"email": "user@example.com",
"password": "securepassword123"
}
Response:
{
"user": { "id": "...", "email": "...", "name": "..." },
"accessToken": "eyJ...",
"refreshToken": "..."
}
```
**Validate Token**
```bash
POST /api/v1/auth/validate
{
"token": "eyJ..."
}
Response:
{
"valid": true,
"payload": {
"sub": "user-id",
"email": "user@example.com",
"role": "user",
"sid": "session-id"
}
}
```
## Development Bypass
For local development, you can bypass auth:
```env
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
```
The guard will inject a mock user:
```typescript
// From JwtAuthGuard when bypass is enabled
request.user = {
userId: process.env.DEV_USER_ID || 'dev-user',
email: 'dev@example.com',
role: 'user',
};
```
## Testing with Auth
### Unit Tests
```typescript
// Mock the guard
const module = await Test.createTestingModule({
controllers: [FileController],
providers: [FileService],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
// Mock user in controller tests
const mockUser = { userId: 'test-user', email: 'test@example.com', role: 'user' };
await controller.listFiles(mockUser);
```
### E2E Tests
```typescript
// Get a real token
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'password' });
const token = loginResponse.body.accessToken;
// Use token in requests
await request(app.getHttpServer())
.get('/api/v1/files')
.set('Authorization', `Bearer ${token}`)
.expect(200);
```
## Security Considerations
1. **Store tokens securely**
- Web: HttpOnly cookies or memory (not localStorage)
- Mobile: SecureStore (not AsyncStorage)
2. **Token refresh**
- Access tokens expire in 1 hour
- Use refresh tokens to get new access tokens
- Handle 401 responses gracefully
3. **CORS configuration**
- Only allow known origins
- Include credentials for cookie-based auth
4. **Never trust client data**
- Always validate token server-side
- Use `@CurrentUser()` decorator, not request body
## Debugging
### Token not validating?
```bash
# 1. Check algorithm (should be EdDSA)
echo $TOKEN | cut -d'.' -f1 | base64 -d
# 2. Check JWKS endpoint
curl http://localhost:3001/api/v1/auth/jwks
# 3. Check issuer/audience
# Should match between signing and validation
```
### 401 errors?
1. Check token exists in Authorization header
2. Check token format: `Bearer <token>`
3. Check token hasn't expired
4. Check MANA_CORE_AUTH_URL is correct

View file

@ -0,0 +1,312 @@
# Code Style Guidelines
## Formatting
### Prettier Configuration
All projects use the root `.prettierrc.json`:
```json
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"]
}
```
### Key Rules
- **Tabs** for indentation (not spaces)
- **Single quotes** for strings
- **Trailing commas** in ES5-compatible positions
- **100 character** line width
- **Semicolons** required
## Naming Conventions
### Files & Directories
| Type | Convention | Example |
| -------------------- | ----------------- | ------------------------------------------------ |
| **Components** | PascalCase | `MessageBubble.svelte`, `ChatInput.tsx` |
| **Services** | kebab-case | `auth.service.ts`, `user-credits.service.ts` |
| **Schemas** | kebab-case | `users.schema.ts`, `batch-generations.schema.ts` |
| **Utilities** | kebab-case | `format-date.ts`, `string-utils.ts` |
| **Types/Interfaces** | kebab-case | `user.types.ts`, `api-response.ts` |
| **Constants** | kebab-case | `error-codes.ts`, `config.ts` |
| **Test files** | `.spec.ts` suffix | `auth.service.spec.ts` |
### Code Identifiers
| Type | Convention | Example |
| ------------------ | ------------------------------------------------ | ---------------------------------- |
| **Classes** | PascalCase | `UserService`, `AuthController` |
| **Interfaces** | PascalCase | `UserData`, `CreateEventDto` |
| **Type aliases** | PascalCase | `Result<T>`, `ErrorCode` |
| **Functions** | camelCase | `findById`, `createUser` |
| **Variables** | camelCase | `userId`, `isLoading` |
| **Constants** | SCREAMING_SNAKE_CASE | `MAX_FILE_SIZE`, `DEFAULT_TIMEOUT` |
| **Enums** | PascalCase (type), SCREAMING_SNAKE_CASE (values) | `ErrorCode.NOT_FOUND` |
| **Private fields** | camelCase (no underscore prefix) | `private db: Database` |
### Database Naming
| Type | Convention | Example |
| ---------------- | ---------------------- | ------------------------------- |
| **Tables** | snake_case, plural | `users`, `user_sessions` |
| **Columns** | snake_case | `user_id`, `created_at` |
| **Foreign keys** | `{entity}_id` | `user_id`, `folder_id` |
| **Booleans** | `is_` or `has_` prefix | `is_deleted`, `has_password` |
| **Timestamps** | `_at` suffix | `created_at`, `deleted_at` |
| **Indexes** | `idx_` prefix | `idx_user_id`, `idx_created_at` |
## TypeScript
### Strict Mode
All projects use strict TypeScript:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
```
### Type Annotations
```typescript
// GOOD - Explicit return types for public APIs
async function findById(id: string): Promise<Result<User>> {
// ...
}
// GOOD - Interface for complex objects
interface CreateUserDto {
email: string;
name: string;
password: string;
}
// BAD - Avoid `any`
function process(data: any) {} // Never do this
// GOOD - Use `unknown` when type is truly unknown
function process(data: unknown) {
if (isUser(data)) {
// Now TypeScript knows it's a User
}
}
```
### Imports
```typescript
// Order: external → internal → relative
import { Injectable } from '@nestjs/common'; // 1. External
import { Result, ErrorCode } from '@manacore/shared-errors'; // 2. Internal packages
import { UserService } from '../services/user.service'; // 3. Relative
// Use named exports (not default)
export { UserService }; // GOOD
export default UserService; // AVOID
// Use type-only imports for types
import type { User } from './user.types';
```
## ESLint Rules
### Critical Rules (Errors)
```javascript
{
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error", // For public APIs
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": ["error", { "allow": ["warn", "error"] }],
}
```
### Recommended Rules (Warnings)
```javascript
{
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/await-thenable": "warn",
"prefer-const": "warn",
}
```
## Comments
### When to Comment
```typescript
// GOOD - Explain WHY, not WHAT
// We use optimistic locking here because concurrent credit operations
// could otherwise result in race conditions and incorrect balances
const [updated] = await this.db
.update(balances)
.set({ amount: newAmount, version: sql`version + 1` })
.where(and(eq(balances.userId, userId), eq(balances.version, currentVersion)))
.returning();
// BAD - Explaining obvious code
// Loop through users
for (const user of users) {
}
// BAD - Outdated comment
// Returns the user's email <-- but function now returns full user object
function getUser() {}
```
### JSDoc for Public APIs
```typescript
/**
* Consumes credits from a user's balance.
*
* @param userId - The user's unique identifier
* @param amount - Number of credits to consume
* @param reason - Human-readable reason for the charge
* @returns Result with the updated balance or an error
*
* @example
* const result = await creditsService.consume(userId, 10, 'AI generation');
* if (!result.ok) {
* logger.error('Credit consumption failed', result.error);
* }
*/
async consume(userId: string, amount: number, reason: string): Promise<Result<Balance>> {
// ...
}
```
## Code Organization
### File Size
- **Maximum**: ~300 lines per file
- **Ideal**: 100-200 lines
- Split large files into focused modules
### Function Size
- **Maximum**: ~50 lines per function
- **Ideal**: 10-25 lines
- Extract complex logic into helper functions
### Module Structure (NestJS)
```
feature/
├── feature.controller.ts # HTTP layer
├── feature.service.ts # Business logic
├── feature.module.ts # DI configuration
├── feature.spec.ts # Tests
└── dto/
├── create-feature.dto.ts
└── update-feature.dto.ts
```
### Component Structure (Svelte/React)
```
components/
├── feature/
│ ├── FeatureList.svelte # Container component
│ ├── FeatureItem.svelte # Presentational component
│ └── feature.types.ts # Shared types
└── ui/
├── Button.svelte # Reusable UI
└── Input.svelte
```
## Anti-Patterns to Avoid
### 1. Magic Numbers/Strings
```typescript
// BAD
if (user.role === 'admin') {
}
if (credits < 10) {
}
// GOOD
const ROLES = { ADMIN: 'admin', USER: 'user' } as const;
const MIN_CREDITS_FOR_OPERATION = 10;
if (user.role === ROLES.ADMIN) {
}
if (credits < MIN_CREDITS_FOR_OPERATION) {
}
```
### 2. Nested Callbacks
```typescript
// BAD
getUser(id, (user) => {
getCredits(user.id, (credits) => {
updateBalance(credits, (result) => {
// ...
});
});
});
// GOOD
const user = await getUser(id);
const credits = await getCredits(user.id);
const result = await updateBalance(credits);
```
### 3. Mutating Parameters
```typescript
// BAD
function processUser(user: User): void {
user.name = user.name.trim(); // Mutates input
}
// GOOD
function processUser(user: User): User {
return { ...user, name: user.name.trim() }; // Returns new object
}
```
### 4. Boolean Trap
```typescript
// BAD - What does `true` mean?
createUser(email, password, true, false);
// GOOD - Use options object
createUser({
email,
password,
sendWelcomeEmail: true,
requireEmailVerification: false,
});
```
## Formatting Commands
```bash
# Format all files
pnpm format
# Check formatting without changes
pnpm format:check
# Format specific project
pnpm --filter @chat/backend format
```

View file

@ -0,0 +1,493 @@
# Database Guidelines
## Overview
All projects use **Drizzle ORM** with **PostgreSQL**. This document covers schema design patterns, naming conventions, and migration strategies.
## ORM: Drizzle
### Why Drizzle?
- Full TypeScript type inference
- SQL-like syntax (no magic)
- Lightweight and fast
- Excellent PostgreSQL support
### Connection Pattern
```typescript
// src/db/connection.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10, // Max connections
idle_timeout: 20, // Seconds before closing idle
connect_timeout: 10, // Connection timeout
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;
```
### NestJS Integration
```typescript
// src/db/database.module.ts
import { Global, Module, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}
```
## Schema Design
### File Organization
```
src/db/
├── schema/
│ ├── index.ts # Exports all schemas
│ ├── users.schema.ts # User-related tables
│ ├── files.schema.ts # File-related tables
│ └── ...
├── connection.ts # DB connection singleton
├── database.module.ts # NestJS module
└── migrations/ # Generated migrations
```
### Table Definition Pattern
```typescript
// src/db/schema/files.schema.ts
import {
pgTable,
uuid,
varchar,
text,
boolean,
timestamp,
bigint,
integer,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const files = pgTable(
'files',
{
// Primary key - always UUID with auto-generation
id: uuid('id').primaryKey().defaultRandom(),
// Foreign keys
userId: varchar('user_id', { length: 255 }).notNull(),
parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }),
// Required fields
name: varchar('name', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 255 }).notNull(),
size: bigint('size', { mode: 'number' }).notNull(),
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
// Optional fields
description: text('description'),
// Boolean flags with defaults
isFavorite: boolean('is_favorite').default(false).notNull(),
isPublic: boolean('is_public').default(false).notNull(),
// Soft delete
isDeleted: boolean('is_deleted').default(false).notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
// Timestamps - ALWAYS include these
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
// Indexes for common queries
userIdIdx: index('idx_files_user_id').on(table.userId),
parentFolderIdx: index('idx_files_parent_folder').on(table.parentFolderId),
createdAtIdx: index('idx_files_created_at').on(table.createdAt),
})
);
// Type exports - ALWAYS include these
export type File = typeof files.$inferSelect;
export type NewFile = typeof files.$inferInsert;
```
### Relations
```typescript
// Define relations separately for clarity
export const filesRelations = relations(files, ({ one, many }) => ({
folder: one(folders, {
fields: [files.parentFolderId],
references: [folders.id],
}),
versions: many(fileVersions),
tags: many(fileTags),
}));
export const foldersRelations = relations(folders, ({ one, many }) => ({
parent: one(folders, {
fields: [folders.parentFolderId],
references: [folders.id],
relationName: 'parentChild',
}),
children: many(folders, { relationName: 'parentChild' }),
files: many(files),
}));
```
## Naming Conventions
### Tables
| Rule | Example |
| -------------------------------------- | -------------------------------- |
| Use snake_case | `user_sessions`, `file_versions` |
| Use plural nouns | `users`, `files`, `tags` |
| Junction tables: `{entity1}_{entity2}` | `file_tags`, `user_roles` |
### Columns
| Type | Convention | Example |
| ----------- | ------------------------------ | ---------------------------- |
| Primary key | `id` | `id` |
| Foreign key | `{entity}_id` | `user_id`, `folder_id` |
| Boolean | `is_` or `has_` prefix | `is_deleted`, `has_password` |
| Timestamp | `_at` suffix | `created_at`, `deleted_at` |
| Count | `_count` suffix | `download_count` |
| Version | `version` or `current_version` | `version` |
### Indexes
```typescript
// Pattern: idx_{table}_{column(s)}
index('idx_files_user_id').on(table.userId);
index('idx_files_created_at').on(table.createdAt);
index('idx_messages_conversation_created').on(table.conversationId, table.createdAt);
```
## Common Patterns
### 1. Soft Deletes
```typescript
// Schema
isDeleted: boolean('is_deleted').default(false).notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
// Query - always filter out deleted
const activeFiles = await db
.select()
.from(files)
.where(and(
eq(files.userId, userId),
eq(files.isDeleted, false) // Always include this
));
// Soft delete
await db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, fileId));
// Hard delete (permanent)
await db
.delete(files)
.where(eq(files.id, fileId));
```
### 2. Timestamps
```typescript
// Schema - always include both
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
// Update - always set updatedAt
await db
.update(files)
.set({ name: newName, updatedAt: new Date() })
.where(eq(files.id, fileId));
```
### 3. Optimistic Locking (for concurrent updates)
```typescript
// Schema
version: integer('version').default(1).notNull(),
// Update with version check
const [updated] = await db
.update(balances)
.set({
amount: newAmount,
version: sql`version + 1`,
updatedAt: new Date(),
})
.where(and(
eq(balances.userId, userId),
eq(balances.version, currentVersion) // Only update if version matches
))
.returning();
if (!updated) {
return err(ErrorCode.CONFLICT, 'Balance was modified by another operation');
}
```
### 4. JSONB for Flexible Data
```typescript
// Schema
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
settings: jsonb('settings').default({}).$type<UserSettings>(),
tags: jsonb('tags').$type<string[]>().default([]),
// Query JSONB
const usersWithTag = await db
.select()
.from(users)
.where(sql`${users.tags} @> '["premium"]'::jsonb`);
```
### 5. Enums
```typescript
// Define enum
export const transactionTypeEnum = pgEnum('transaction_type', [
'purchase',
'usage',
'refund',
'bonus',
'adjustment',
]);
// Use in table
type: transactionTypeEnum('type').notNull(),
// TypeScript type
type TransactionType = typeof transactionTypeEnum.enumValues[number];
```
### 6. Pagination
```typescript
async function getPaginated(
userId: string,
page: number = 1,
limit: number = 20
): Promise<Result<{ items: File[]; total: number }>> {
const offset = (page - 1) * limit;
const [items, countResult] = await Promise.all([
db
.select()
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false)))
.orderBy(desc(files.createdAt))
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false))),
]);
return ok({ items, total: countResult[0].count });
}
```
## Migrations
### Configuration
```typescript
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
```
### Commands
```bash
# Generate migration from schema changes
pnpm drizzle-kit generate
# Push schema directly (development only)
pnpm drizzle-kit push
# Open Drizzle Studio
pnpm drizzle-kit studio
# Run migrations (production)
pnpm db:migrate
```
### Migration Runner
```typescript
// src/db/migrate.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
async function runMigrations() {
const connection = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(connection);
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete');
await connection.end();
}
runMigrations().catch(console.error);
```
## Query Patterns
### Select with Joins
```typescript
const filesWithTags = await db
.select({
file: files,
tags: sql<string[]>`array_agg(${tags.name})`,
})
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id))
.where(eq(files.userId, userId))
.groupBy(files.id);
```
### Transactions
```typescript
const result = await db.transaction(async (tx) => {
// All operations in same transaction
const [file] = await tx.insert(files).values(newFile).returning();
await tx.insert(fileVersions).values({ fileId: file.id, versionNumber: 1 });
return file;
});
```
### Upsert
```typescript
await db
.insert(userSettings)
.values({ userId, theme: 'dark' })
.onConflictDoUpdate({
target: userSettings.userId,
set: { theme: 'dark', updatedAt: new Date() },
});
```
## Anti-Patterns
### 1. N+1 Queries
```typescript
// BAD - N+1 queries
const files = await db.select().from(files);
for (const file of files) {
const tags = await db.select().from(tags).where(eq(tags.fileId, file.id)); // N queries!
}
// GOOD - Single query with join
const filesWithTags = await db
.select()
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id));
```
### 2. Missing Indexes
```typescript
// If you frequently query by a column, add an index
// BAD - No index on frequently queried column
const userFiles = await db.select().from(files).where(eq(files.userId, userId));
// GOOD - Index defined in schema
}, (table) => ({
userIdIdx: index('idx_files_user_id').on(table.userId),
}));
```
### 3. Storing Derived Data
```typescript
// BAD - Storing calculated totals that can become stale
totalFiles: integer('total_files'),
// GOOD - Calculate when needed
const { count } = await db
.select({ count: sql<number>`count(*)` })
.from(files)
.where(eq(files.folderId, folderId));
```

View file

@ -0,0 +1,605 @@
# Error Handling Guidelines
## Philosophy: Go-Style Error Handling
We use **explicit error handling** inspired by Go's error handling pattern. Instead of throwing exceptions everywhere, we return `Result<T>` types that force callers to handle errors explicitly.
### Why?
1. **Explicit over implicit** - Errors are part of the function signature
2. **No surprise exceptions** - You know exactly what can fail
3. **Consistent error codes** - Same codes across frontend and backend
4. **Better error messages** - Structured errors with codes and context
## Package: @manacore/shared-errors
The error handling system is implemented in `packages/shared-errors/`. Import from it:
```typescript
import {
// Result type and helpers
Result,
AsyncResult,
ok,
err,
isOk,
isErr,
unwrap,
unwrapOr,
map,
andThen,
match,
tryCatch,
tryCatchAsync,
combine,
// Error codes
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
// Error classes
AppError,
ValidationError,
NotFoundError,
AuthError,
CreditError,
ServiceError,
RateLimitError,
NetworkError,
DatabaseError,
// Type guards
isAppError,
isValidationError,
isNotFoundError,
hasErrorCode,
isRetryable,
getHttpStatus,
// Utilities
wrap,
toAppError,
} from '@manacore/shared-errors';
```
## Core Types
### Result Type
```typescript
// Result represents success or failure
export type Result<T, E extends AppError = AppError> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
// Async version for async functions
export type AsyncResult<T, E extends AppError = AppError> = Promise<Result<T, E>>;
// Create success result
const user = ok({ id: '123', name: 'John' });
// Create failure result
const error = err(new NotFoundError('User', userId));
```
### Error Classes
```typescript
// Base error class
class AppError extends Error {
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
}
// Specialized error classes
ValidationError.invalidInput('email', 'must be valid email');
NotFoundError.user(userId);
AuthError.tokenExpired();
CreditError.insufficient(required, available);
ServiceError.generation('AI generation failed');
RateLimitError.exceeded(retryAfter);
NetworkError.timeout();
DatabaseError.constraint('unique_email');
```
## Error Codes
All error codes are defined in `@manacore/shared-errors`:
```typescript
export enum ErrorCode {
// Validation (400)
VALIDATION_FAILED = 'VALIDATION_FAILED',
INVALID_INPUT = 'INVALID_INPUT',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
INVALID_FORMAT = 'INVALID_FORMAT',
// Authentication (401)
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Authorization (403)
PERMISSION_DENIED = 'PERMISSION_DENIED',
RESOURCE_NOT_OWNED = 'RESOURCE_NOT_OWNED',
// Not Found (404)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND',
// Payment/Credit (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// Service Errors (500)
INTERNAL_ERROR = 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
GENERATION_FAILED = 'GENERATION_FAILED',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
// Network Errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
```
### HTTP Status Mapping
```typescript
import { ERROR_CODE_TO_HTTP_STATUS, getHttpStatus } from '@manacore/shared-errors';
// Get HTTP status for an error code
const status = ERROR_CODE_TO_HTTP_STATUS[ErrorCode.RESOURCE_NOT_FOUND]; // 404
// Or use helper function
const status = getHttpStatus(error); // Returns appropriate HTTP status
```
### Retryable Errors
```typescript
import { isRetryable } from '@manacore/shared-errors';
// Check if an error is worth retrying
if (isRetryable(error)) {
await delay(1000);
return retry(operation);
}
```
## Backend Usage
### Service Layer
```typescript
// src/files/file.service.ts
import { Injectable, Inject } from '@nestjs/common';
import {
AsyncResult,
ok,
err,
isOk,
NotFoundError,
DatabaseError,
ValidationError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { files, File, NewFile } from '../db/schema';
@Injectable()
export class FileService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findById(id: string, userId: string): AsyncResult<File> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
if (!file) {
return err(new NotFoundError('File', id));
}
return ok(file);
} catch (error) {
return err(DatabaseError.query('Failed to fetch file', error));
}
}
async create(userId: string, dto: CreateFileDto): AsyncResult<File> {
// Validation
if (!dto.name?.trim()) {
return err(ValidationError.required('name'));
}
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId,
};
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
// Handle unique constraint violations
if (error.code === '23505') {
return err(DatabaseError.constraint('A file with this name already exists'));
}
return err(DatabaseError.query('Failed to create file', error));
}
}
async delete(id: string, userId: string): AsyncResult<void> {
const fileResult = await this.findById(id, userId);
if (!isOk(fileResult)) {
return fileResult; // Propagate error
}
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
return ok(undefined);
} catch (error) {
return err(DatabaseError.query('Failed to delete file', error));
}
}
}
```
### Controller Layer
```typescript
// src/files/file.controller.ts
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { isOk, unwrap } from '@manacore/shared-errors';
import { FileService } from './file.service';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get(':id')
async getFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.findById(id, user.userId);
if (!isOk(result)) {
throw result.error; // AppError extends Error, caught by exception filter
}
return { file: result.value };
}
@Post()
async createFile(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.create(user.userId, dto);
if (!isOk(result)) {
throw result.error;
}
return { file: result.value };
}
@Delete(':id')
async deleteFile(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
// Alternative: use unwrap() which throws on error
unwrap(await this.fileService.delete(id, user.userId));
return { success: true };
}
}
```
### Exception Filter
The package provides a ready-to-use exception filter:
```typescript
// In main.ts or app.module.ts
import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
// Apply globally
app.useGlobalFilters(new AppExceptionFilter());
```
The filter automatically:
- Maps `ErrorCode` to HTTP status codes
- Returns consistent JSON error format
- Logs server errors (5xx)
Custom filter example:
```typescript
// src/common/filters/app-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
import { AppError, isAppError, getHttpStatus } from '@manacore/shared-errors';
@Catch(AppError)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('AppException');
catch(exception: AppError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = getHttpStatus(exception);
// Log server errors
if (status >= 500) {
this.logger.error(exception.message, exception.stack);
}
response.status(status).json({
ok: false,
error: {
code: exception.code,
message: exception.message,
},
});
}
}
```
## Frontend Usage
### API Client
```typescript
// lib/api/client.ts
import { Result, err, ErrorCode, AppError } from '@manacore/shared-errors';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
try {
const token = await getAuthToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? {
code: ErrorCode.UNKNOWN_ERROR,
message: 'Request failed',
},
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return err(ErrorCode.EXTERNAL_SERVICE_ERROR, 'Network request failed');
}
}
// Typed API methods
export const api = {
files: {
get: (id: string) => apiRequest<File>(`/files/${id}`),
list: (folderId?: string) => apiRequest<File[]>(`/files?folderId=${folderId ?? ''}`),
create: (data: CreateFileDto) =>
apiRequest<File>('/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => apiRequest<void>(`/files/${id}`, { method: 'DELETE' }),
},
};
```
### Component Usage (Svelte 5)
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadFiles() {
loading = true;
error = null;
const result = await api.files.list();
if (!result.ok) {
// Handle specific error codes
switch (result.error.code) {
case ErrorCode.UNAUTHORIZED:
goto('/login');
break;
case ErrorCode.FORBIDDEN:
error = 'You do not have permission to view these files';
break;
default:
error = result.error.message;
}
} else {
files = result.data;
}
loading = false;
}
async function deleteFile(id: string) {
const result = await api.files.delete(id);
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
files = files.filter((f) => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
</script>
```
### Component Usage (React Native)
```typescript
// hooks/useFiles.ts
import { useState, useCallback } from 'react';
import { api } from '../services/api';
import { ErrorCode, Result, AppError } from '@manacore/shared-errors';
export function useFiles() {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
const result = await api.files.list();
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
setLoading(false);
}, []);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
if (!result.ok) {
return false;
}
setFiles((prev) => prev.filter((f) => f.id !== id));
return true;
}, []);
return { files, loading, error, loadFiles, deleteFile };
}
```
## Error Chaining
### Wrapping Errors with Context
```typescript
async function processUpload(userId: string, file: File): Promise<Result<FileRecord>> {
// Validate file
const validationResult = validateFile(file);
if (!validationResult.ok) {
return validationResult; // Return validation error as-is
}
// Upload to storage
const uploadResult = await storageService.upload(file);
if (!uploadResult.ok) {
// Add context to storage error
return err(ErrorCode.UPLOAD_FAILED, `Failed to upload file: ${uploadResult.error.message}`, {
originalError: uploadResult.error,
});
}
// Save to database
const saveResult = await fileService.create(userId, {
name: file.name,
storagePath: uploadResult.data.path,
});
if (!saveResult.ok) {
// Cleanup on failure
await storageService.delete(uploadResult.data.path);
return saveResult;
}
return saveResult;
}
```
## Logging Errors
```typescript
import { Logger } from '@nestjs/common';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
try {
// ... operation
} catch (error) {
// Log full error for debugging
this.logger.error('Failed to create file', {
userId,
fileName: dto.name,
error: error.message,
stack: error.stack,
});
// Return user-friendly error
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
}
```
## Best Practices
### Do's
1. **Always check result.ok before accessing data**
2. **Use specific error codes** rather than generic ones
3. **Include helpful messages** for debugging
4. **Log errors at the service layer**
5. **Return early on errors** to avoid nested conditions
### Don'ts
1. **Don't throw exceptions in services** - use Result instead
2. **Don't expose internal error details** to users
3. **Don't use try-catch for flow control**
4. **Don't ignore error results** - always handle them
5. **Don't use string error codes** - use the ErrorCode enum

View file

@ -0,0 +1,789 @@
# Expo Mobile Guidelines
## Overview
All mobile applications use **Expo SDK 52+** with **React Native** and **Expo Router** for file-based routing. Styling uses **NativeWind** (Tailwind for React Native).
## Project Structure
```
apps/{project}/apps/mobile/
├── app/
│ ├── _layout.tsx # Root layout (Stack)
│ ├── index.tsx # Home screen
│ ├── (auth)/ # Auth screens
│ │ ├── _layout.tsx
│ │ ├── login.tsx
│ │ └── register.tsx
│ ├── (drawer)/ # Main app with drawer
│ │ ├── _layout.tsx
│ │ └── (tabs)/
│ │ ├── _layout.tsx
│ │ ├── home.tsx
│ │ ├── files.tsx
│ │ └── settings.tsx
│ └── file/[id].tsx # Dynamic route
├── components/
│ ├── ui/ # Reusable UI components
│ ├── layout/ # Layout components
│ └── {feature}/ # Feature components
├── context/
│ └── AuthProvider.tsx # Auth context
├── hooks/
│ ├── useAuth.ts
│ └── useFiles.ts
├── services/
│ └── api.ts # API client
├── lib/
│ └── utils.ts # Utilities
├── types/
│ └── index.ts
├── assets/ # Images, fonts
├── app.json # Expo config
├── tailwind.config.js # NativeWind config
├── babel.config.js
└── package.json
```
## App Entry Point
```tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AuthProvider } from '../context/AuthProvider';
import { ThemeProvider } from '../context/ThemeProvider';
import '../global.css'; // NativeWind styles
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<AuthProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(drawer)" />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
</Stack>
</AuthProvider>
</ThemeProvider>
</GestureHandlerRootView>
);
}
```
## Navigation
### Drawer Navigation
```tsx
// app/(drawer)/_layout.tsx
import { Drawer } from 'expo-router/drawer';
import CustomDrawer from '../../components/layout/CustomDrawer';
export default function DrawerLayout() {
return (
<Drawer
drawerContent={(props) => <CustomDrawer {...props} />}
screenOptions={{
drawerType: 'front',
headerShown: false,
}}
>
<Drawer.Screen name="(tabs)" options={{ title: 'Home' }} />
<Drawer.Screen name="settings" options={{ title: 'Settings' }} />
</Drawer>
);
}
```
### Tab Navigation
```tsx
// app/(drawer)/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#0A84FF',
headerShown: false,
}}
>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="files"
options={{
title: 'Files',
tabBarIcon: ({ color, size }) => <Ionicons name="folder" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => <Ionicons name="settings" size={size} color={color} />,
}}
/>
</Tabs>
);
}
```
### Programmatic Navigation
```tsx
import { router } from 'expo-router';
// Navigate to route
router.push('/files');
router.push('/file/123');
// Navigate with params
router.push({ pathname: '/file/[id]', params: { id: '123' } });
// Replace (no back)
router.replace('/home');
// Go back
router.back();
// Navigate to modal
router.push('/modal');
```
## Auth Context
```tsx
// context/AuthProvider.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as SecureStore from 'expo-secure-store';
import { router } from 'expo-router';
import { api } from '../services/api';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStoredAuth();
}, []);
async function loadStoredAuth() {
try {
const storedToken = await SecureStore.getItemAsync(TOKEN_KEY);
const storedUser = await SecureStore.getItemAsync(USER_KEY);
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
} catch (error) {
console.error('Failed to load auth:', error);
} finally {
setLoading(false);
}
}
async function login(email: string, password: string): Promise<boolean> {
const result = await api.auth.login({ email, password });
if (!result.ok) {
return false;
}
const { token: newToken, user: newUser } = result.data;
await SecureStore.setItemAsync(TOKEN_KEY, newToken);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
return true;
}
async function logout() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
setToken(null);
setUser(null);
router.replace('/login');
}
return (
<AuthContext.Provider
value={{
user,
token,
loading,
login,
logout,
isAuthenticated: !!token,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
```
## Custom Hooks
### Data Fetching Hook
```tsx
// hooks/useFiles.ts
import { useState, useCallback, useEffect } from 'react';
import { api } from '../services/api';
import type { File, AppError } from '../types';
interface UseFilesResult {
files: File[];
loading: boolean;
error: AppError | null;
loadFiles: (folderId?: string) => Promise<void>;
deleteFile: (id: string) => Promise<boolean>;
refresh: () => Promise<void>;
}
export function useFiles(initialFolderId?: string): UseFilesResult {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AppError | null>(null);
const [folderId, setFolderId] = useState(initialFolderId);
const loadFiles = useCallback(
async (newFolderId?: string) => {
const targetFolderId = newFolderId ?? folderId;
setFolderId(targetFolderId);
setLoading(true);
setError(null);
const result = await api.files.list(targetFolderId);
if (result.ok) {
setFiles(result.data);
} else {
setError(result.error);
}
setLoading(false);
},
[folderId]
);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
if (result.ok) {
setFiles((prev) => prev.filter((f) => f.id !== id));
return true;
}
setError(result.error);
return false;
}, []);
const refresh = useCallback(() => loadFiles(), [loadFiles]);
useEffect(() => {
loadFiles();
}, []);
return { files, loading, error, loadFiles, deleteFile, refresh };
}
```
### Mutation Hook
```tsx
// hooks/useCreateFile.ts
import { useState, useCallback } from 'react';
import { api } from '../services/api';
import type { File, CreateFileDto, AppError } from '../types';
interface UseCreateFileResult {
create: (data: CreateFileDto) => Promise<File | null>;
loading: boolean;
error: AppError | null;
}
export function useCreateFile(): UseCreateFileResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<AppError | null>(null);
const create = useCallback(async (data: CreateFileDto): Promise<File | null> => {
setLoading(true);
setError(null);
const result = await api.files.create(data);
setLoading(false);
if (result.ok) {
return result.data;
}
setError(result.error);
return null;
}, []);
return { create, loading, error };
}
```
## API Client
```typescript
// services/api.ts
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
import type { Result, AppError } from '@manacore/shared-errors';
import { ErrorCode } from '@manacore/shared-errors';
const API_URL = Constants.expoConfig?.extra?.apiUrl ?? 'http://localhost:3016';
const TOKEN_KEY = 'auth_token';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(TOKEN_KEY);
} catch {
return null;
}
}
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
try {
const token = await getToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' },
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return {
ok: false,
error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' },
};
}
}
export const api = {
auth: {
login: (data: { email: string; password: string }) =>
request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify(data),
}),
register: (data: { email: string; password: string; name: string }) =>
request<{ token: string; user: User }>('/api/v1/auth/register', {
method: 'POST',
body: JSON.stringify(data),
}),
},
files: {
list: (folderId?: string) =>
request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) => request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
delete: (id: string) => request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
},
};
```
## Components
### Screen Component
```tsx
// app/(drawer)/(tabs)/files.tsx
import { View, FlatList, RefreshControl } from 'react-native';
import { useFiles } from '../../../hooks/useFiles';
import { FileCard } from '../../../components/files/FileCard';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { ErrorView } from '../../../components/ui/ErrorView';
import { EmptyState } from '../../../components/ui/EmptyState';
export default function FilesScreen() {
const { files, loading, error, refresh } = useFiles();
if (loading && files.length === 0) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorView message={error.message} onRetry={refresh} />;
}
return (
<View className="flex-1 bg-background">
<FlatList
data={files}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <FileCard file={item} />}
refreshControl={<RefreshControl refreshing={loading} onRefresh={refresh} />}
ListEmptyComponent={
<EmptyState title="No files" description="Upload your first file to get started" />
}
contentContainerStyle={{ padding: 16, gap: 12 }}
/>
</View>
);
}
```
### Reusable Component
```tsx
// components/files/FileCard.tsx
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import type { File } from '../../types';
import { formatBytes, formatDate } from '../../lib/utils';
interface FileCardProps {
file: File;
onDelete?: () => void;
}
export function FileCard({ file, onDelete }: FileCardProps) {
const handlePress = () => {
router.push({ pathname: '/file/[id]', params: { id: file.id } });
};
return (
<Pressable
onPress={handlePress}
className="bg-card rounded-xl p-4 border border-border active:opacity-80"
>
<View className="flex-row items-center gap-3">
<View className="w-10 h-10 bg-primary/10 rounded-lg items-center justify-center">
<Ionicons name="document" size={20} color="#0A84FF" />
</View>
<View className="flex-1">
<Text className="font-medium text-foreground" numberOfLines={1}>
{file.name}
</Text>
<Text className="text-sm text-muted-foreground">
{formatBytes(file.size)} • {formatDate(file.createdAt)}
</Text>
</View>
{onDelete && (
<Pressable
onPress={onDelete}
className="p-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</Pressable>
)}
</View>
</Pressable>
);
}
```
### UI Component
```tsx
// components/ui/Button.tsx
import { Pressable, Text, ActivityIndicator, PressableProps } from 'react-native';
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva('flex-row items-center justify-center rounded-xl', {
variants: {
variant: {
primary: 'bg-primary',
secondary: 'bg-secondary',
outline: 'border border-border bg-transparent',
ghost: 'bg-transparent',
},
size: {
sm: 'h-9 px-3',
md: 'h-11 px-4',
lg: 'h-14 px-6',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
const textVariants = cva('font-medium', {
variants: {
variant: {
primary: 'text-white',
secondary: 'text-secondary-foreground',
outline: 'text-foreground',
ghost: 'text-foreground',
},
size: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
interface ButtonProps extends PressableProps, VariantProps<typeof buttonVariants> {
children: string;
loading?: boolean;
}
export function Button({
children,
variant,
size,
loading = false,
disabled,
className,
...props
}: ButtonProps) {
return (
<Pressable
disabled={disabled || loading}
className={`${buttonVariants({ variant, size })} ${disabled ? 'opacity-50' : ''} ${className}`}
{...props}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? '#fff' : '#000'} />
) : (
<Text className={textVariants({ variant, size })}>{children}</Text>
)}
</Pressable>
);
}
```
## NativeWind Setup
### Configuration
```javascript
// tailwind.config.js
module.exports = {
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
primary: '#0A84FF',
secondary: '#5856D6',
background: '#F2F2F7',
foreground: '#1C1C1E',
card: '#FFFFFF',
border: '#E5E5EA',
muted: '#8E8E93',
'muted-foreground': '#8E8E93',
},
},
},
};
```
### Usage
```tsx
// NativeWind uses className prop
<View className="flex-1 bg-background p-4">
<Text className="text-lg font-bold text-foreground">Title</Text>
<Text className="text-muted-foreground">Subtitle</Text>
</View>
// Conditional classes
<View className={`p-4 rounded-xl ${selected ? 'bg-primary' : 'bg-card'}`}>
// Dynamic classes
<Text className={`text-${size} font-${weight}`}>
// Platform-specific (use Platform.select for complex cases)
<View className="ios:pt-12 android:pt-4">
```
## Form Handling
```tsx
// app/(auth)/login.tsx
import { useState } from 'react';
import { View, Text, TextInput, Alert } from 'react-native';
import { router } from 'expo-router';
import { useAuth } from '../../context/AuthProvider';
import { Button } from '../../components/ui/Button';
export default function LoginScreen() {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleLogin() {
if (!email.trim() || !password) {
Alert.alert('Error', 'Please fill in all fields');
return;
}
setLoading(true);
const success = await login(email.trim(), password);
setLoading(false);
if (success) {
router.replace('/');
} else {
Alert.alert('Error', 'Invalid email or password');
}
}
return (
<View className="flex-1 bg-background p-6 justify-center">
<Text className="text-3xl font-bold text-foreground mb-8 text-center">Welcome Back</Text>
<View className="gap-4">
<View>
<Text className="text-sm font-medium text-muted-foreground mb-1">Email</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
className="h-12 px-4 bg-card border border-border rounded-xl text-foreground"
/>
</View>
<View>
<Text className="text-sm font-medium text-muted-foreground mb-1">Password</Text>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="••••••••"
secureTextEntry
className="h-12 px-4 bg-card border border-border rounded-xl text-foreground"
/>
</View>
<Button onPress={handleLogin} loading={loading} className="mt-4">
Sign In
</Button>
</View>
</View>
);
}
```
## Environment Variables
```typescript
// Access via Expo Constants
import Constants from 'expo-constants';
const API_URL = Constants.expoConfig?.extra?.apiUrl;
// app.json / app.config.js
{
"expo": {
"extra": {
"apiUrl": process.env.EXPO_PUBLIC_API_URL
}
}
}
// .env
EXPO_PUBLIC_API_URL=http://localhost:3016
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Best Practices
### Do's
1. **Use Expo Router** for navigation (file-based)
2. **Use NativeWind** for styling (consistent with web)
3. **Use SecureStore** for sensitive data (tokens)
4. **Create custom hooks** for data fetching
5. **Use TypeScript** with strict mode
### Don'ts
1. **Don't use inline styles** - use NativeWind classes
2. **Don't store tokens in AsyncStorage** - use SecureStore
3. **Don't make API calls in render** - use effects/hooks
4. **Don't ignore loading states** - always show feedback
5. **Don't forget error handling** - handle all error cases

View file

@ -0,0 +1,659 @@
# NestJS Backend Guidelines
## Overview
All backend services use NestJS with a consistent architecture. This guide covers controllers, services, DTOs, modules, and integration with the error handling system.
## Project Structure
```
apps/{project}/apps/backend/
├── src/
│ ├── main.ts # Bootstrap
│ ├── app.module.ts # Root module
│ ├── db/
│ │ ├── schema/ # Drizzle schemas
│ │ ├── connection.ts # DB singleton
│ │ ├── database.module.ts # NestJS module
│ │ └── migrations/ # Migration files
│ ├── common/
│ │ ├── filters/ # Exception filters
│ │ ├── guards/ # Custom guards
│ │ └── decorators/ # Custom decorators
│ ├── health/
│ │ ├── health.controller.ts
│ │ └── health.module.ts
│ └── {feature}/
│ ├── {feature}.controller.ts
│ ├── {feature}.service.ts
│ ├── {feature}.module.ts
│ ├── {feature}.spec.ts
│ └── dto/
│ ├── create-{feature}.dto.ts
│ └── update-{feature}.dto.ts
├── test/
│ ├── jest-e2e.json
│ └── app.e2e-spec.ts
├── drizzle.config.ts
├── nest-cli.json
├── package.json
└── tsconfig.json
```
## Bootstrap (main.ts)
```typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { AppModule } from './app.module';
import { AppExceptionFilter } from './common/filters/app-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
// CORS
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((o) => o.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8081',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Reject unknown properties
transform: true, // Auto-transform types
transformOptions: {
enableImplicitConversion: true,
},
})
);
// Global exception filter
app.useGlobalFilters(new AppExceptionFilter());
// API prefix
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
}
bootstrap();
```
## App Module
```typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { FileModule } from './file/file.module';
import { FolderModule } from './folder/folder.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
FileModule,
FolderModule,
],
})
export class AppModule {}
```
## Controllers
### Basic Pattern
```typescript
// src/file/file.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AppException } from '@manacore/shared-errors';
import { FileService } from './file.service';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Controller('files')
@UseGuards(JwtAuthGuard) // Apply to all routes in controller
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get()
async list(@CurrentUser() user: CurrentUserData, @Query() query: QueryFilesDto) {
const result = await this.fileService.findAll(user.userId, query);
if (!result.ok) throw new AppException(result.error);
return { files: result.data };
}
@Get(':id')
async getById(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.findById(id, user.userId);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
@Post()
async create(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.create(user.userId, dto);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
@Patch(':id')
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateFileDto,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.update(id, user.userId, dto);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
@Delete(':id')
async delete(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.delete(id, user.userId);
if (!result.ok) throw new AppException(result.error);
return { success: true };
}
}
```
### Public Endpoints (No Auth)
```typescript
@Controller('public')
export class PublicController {
@Get('shares/:token') // No @UseGuards - public access
async getSharedItem(@Param('token') token: string) {
const result = await this.shareService.findByToken(token);
if (!result.ok) throw new AppException(result.error);
return { item: result.data };
}
}
```
## Services
### Basic Pattern with Result Types
```typescript
// src/file/file.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import { Result, ok, err, ErrorCode } from '@manacore/shared-errors';
import { DATABASE_CONNECTION, Database } from '../db/database.module';
import { files, File, NewFile } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string, query: QueryFilesDto): Promise<Result<File[]>> {
try {
const conditions = [eq(files.userId, userId), eq(files.isDeleted, false)];
if (query.folderId) {
conditions.push(eq(files.parentFolderId, query.folderId));
}
const result = await this.db
.select()
.from(files)
.where(and(...conditions))
.orderBy(desc(files.createdAt))
.limit(query.limit ?? 50)
.offset(query.offset ?? 0);
return ok(result);
} catch (error) {
this.logger.error('Failed to fetch files', { userId, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to fetch files');
}
}
async findById(id: string, userId: string): Promise<Result<File>> {
try {
const [file] = await this.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
if (!file) {
return err(ErrorCode.FILE_NOT_FOUND, `File ${id} not found`);
}
return ok(file);
} catch (error) {
this.logger.error('Failed to fetch file', { id, userId, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to fetch file');
}
}
async create(userId: string, dto: CreateFileDto): Promise<Result<File>> {
// Validation
if (!dto.name?.trim()) {
return err(ErrorCode.MISSING_REQUIRED_FIELD, 'File name is required');
}
try {
const newFile: NewFile = {
userId,
name: dto.name.trim(),
originalName: dto.originalName,
mimeType: dto.mimeType,
size: dto.size,
storagePath: dto.storagePath,
storageKey: dto.storageKey,
parentFolderId: dto.folderId ?? null,
};
const [created] = await this.db.insert(files).values(newFile).returning();
return ok(created);
} catch (error) {
if (error.code === '23505') {
return err(ErrorCode.DUPLICATE_ENTRY, 'A file with this name already exists');
}
this.logger.error('Failed to create file', { userId, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
async update(id: string, userId: string, dto: UpdateFileDto): Promise<Result<File>> {
// Check ownership first
const existingResult = await this.findById(id, userId);
if (!existingResult.ok) return existingResult;
try {
const [updated] = await this.db
.update(files)
.set({
...(dto.name && { name: dto.name.trim() }),
...(dto.parentFolderId !== undefined && { parentFolderId: dto.parentFolderId }),
updatedAt: new Date(),
})
.where(eq(files.id, id))
.returning();
return ok(updated);
} catch (error) {
this.logger.error('Failed to update file', { id, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to update file');
}
}
async delete(id: string, userId: string): Promise<Result<void>> {
// Check ownership first
const existingResult = await this.findById(id, userId);
if (!existingResult.ok) return existingResult;
try {
await this.db
.update(files)
.set({ isDeleted: true, deletedAt: new Date() })
.where(eq(files.id, id));
return ok(undefined);
} catch (error) {
this.logger.error('Failed to delete file', { id, error: error.message });
return err(ErrorCode.DATABASE_ERROR, 'Failed to delete file');
}
}
}
```
### Service with External Dependencies
```typescript
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private readonly storageService: StorageService,
private readonly fileService: FileService
) {}
async uploadFile(
userId: string,
file: Express.Multer.File,
folderId?: string
): Promise<Result<File>> {
// 1. Upload to storage
const storageResult = await this.storageService.upload(
generateStorageKey(userId, file.originalname),
file.buffer,
{ contentType: file.mimetype }
);
if (!storageResult.ok) {
return err(ErrorCode.UPLOAD_FAILED, 'Failed to upload file to storage');
}
// 2. Create database record
const createResult = await this.fileService.create(userId, {
name: file.originalname,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
storagePath: storageResult.data.path,
storageKey: storageResult.data.key,
folderId,
});
if (!createResult.ok) {
// Cleanup on failure
await this.storageService.delete(storageResult.data.key);
return createResult;
}
return createResult;
}
}
```
## DTOs
### Create DTO
```typescript
// src/file/dto/create-file.dto.ts
import { IsString, IsOptional, IsNumber, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateFileDto {
@IsString()
@MaxLength(500)
name: string;
@IsOptional()
@IsString()
@MaxLength(500)
originalName?: string;
@IsString()
@MaxLength(255)
mimeType: string;
@IsNumber()
@Min(0)
size: number;
@IsString()
@MaxLength(1000)
storagePath: string;
@IsString()
@MaxLength(500)
storageKey: string;
@IsOptional()
@IsUUID()
folderId?: string;
}
```
### Update DTO (Partial)
```typescript
// src/file/dto/update-file.dto.ts
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
export class UpdateFileDto {
@IsOptional()
@IsString()
@MaxLength(500)
name?: string;
@IsOptional()
@IsUUID()
parentFolderId?: string | null;
}
```
### Query DTO
```typescript
// src/file/dto/query-files.dto.ts
import { IsOptional, IsUUID, IsNumber, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class QueryFilesDto {
@IsOptional()
@IsUUID()
folderId?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 50;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(0)
offset?: number = 0;
}
```
### DTO Index
```typescript
// src/file/dto/index.ts
export * from './create-file.dto';
export * from './update-file.dto';
export * from './query-files.dto';
```
## Modules
```typescript
// src/file/file.module.ts
import { Module } from '@nestjs/common';
import { FileController } from './file.controller';
import { FileService } from './file.service';
import { UploadService } from './upload.service';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [StorageModule],
controllers: [FileController],
providers: [FileService, UploadService],
exports: [FileService], // Export for use in other modules
})
export class FileModule {}
```
## Exception Filter
```typescript
// src/common/filters/app-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
import { AppException, ERROR_STATUS_MAP, ErrorCode } from '@manacore/shared-errors';
@Catch(AppException)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(AppExceptionFilter.name);
catch(exception: AppException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = ERROR_STATUS_MAP[exception.error.code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
// Log server errors
if (status >= 500) {
this.logger.error('Server error', {
code: exception.error.code,
message: exception.error.message,
details: exception.error.details,
});
}
response.status(status).json({
ok: false,
error: {
code: exception.error.code,
message: exception.error.message,
...(process.env.NODE_ENV === 'development' && {
details: exception.error.details,
}),
},
});
}
}
```
## File Upload
```typescript
// src/file/file.controller.ts
import { UseInterceptors, UploadedFile, ParseFilePipe, MaxFileSizeValidator } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 100 * 1024 * 1024 }), // 100MB
],
})
)
file: Express.Multer.File,
@Query('folderId') folderId: string | undefined,
@CurrentUser() user: CurrentUserData
) {
const result = await this.uploadService.uploadFile(user.userId, file, folderId);
if (!result.ok) throw new AppException(result.error);
return { file: result.data };
}
}
```
## Health Check
```typescript
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { DATABASE_CONNECTION, Database } from '../db/database.module';
import { sql } from 'drizzle-orm';
@Controller('health')
export class HealthController {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
@Get()
async check() {
try {
await this.db.execute(sql`SELECT 1`);
return {
status: 'ok',
timestamp: new Date().toISOString(),
database: 'connected',
};
} catch (error) {
return {
status: 'error',
timestamp: new Date().toISOString(),
database: 'disconnected',
};
}
}
}
```
## API Response Format
### Success Responses
```typescript
// Single resource
{ file: { id: '...', name: '...', ... } }
// Multiple resources
{ files: [...] }
// With pagination
{ files: [...], total: 100, page: 1, limit: 20 }
// Action success
{ success: true }
// Action with data
{ success: true, message: 'File moved', file: {...} }
```
### Error Responses
```typescript
{
ok: false,
error: {
code: 'ERR_4003',
message: 'File not found'
}
}
```
## Environment Variables
```env
# Required
NODE_ENV=development
PORT=3016
DATABASE_URL=postgresql://user:pass@localhost:5432/db
MANA_CORE_AUTH_URL=http://localhost:3001
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Optional - Development bypass
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
```

View file

@ -0,0 +1,764 @@
# SvelteKit Web Guidelines
## Overview
All web applications use **SvelteKit 2** with **Svelte 5** in runes mode. This guide covers component patterns, state management, routing, and API integration.
## Project Structure
```
apps/{project}/apps/web/
├── src/
│ ├── app.html # HTML template
│ ├── app.css # Global styles (Tailwind)
│ ├── app.d.ts # Type declarations
│ ├── hooks.server.ts # Server hooks (auth)
│ ├── lib/
│ │ ├── components/ # Reusable components
│ │ │ ├── ui/ # Generic UI components
│ │ │ └── {feature}/ # Feature-specific components
│ │ ├── stores/ # Svelte 5 stores (.svelte.ts)
│ │ ├── api/ # API client
│ │ ├── utils/ # Utilities
│ │ └── types/ # TypeScript types
│ └── routes/
│ ├── +layout.svelte # Root layout
│ ├── +page.svelte # Home page
│ ├── (auth)/ # Auth route group
│ │ ├── login/
│ │ └── register/
│ └── (protected)/ # Protected route group
│ ├── +layout.svelte
│ ├── files/
│ └── settings/
├── static/ # Static assets
├── svelte.config.js
├── vite.config.ts
├── tailwind.config.js
└── package.json
```
## Svelte 5 Runes
### State with $state
```svelte
<script lang="ts">
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Object state
let user = $state<User | null>(null);
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
</script>
```
### Derived Values with $derived
```svelte
<script lang="ts">
let count = $state(0);
let items = $state<Item[]>([]);
// Computed value - updates automatically
const doubled = $derived(count * 2);
const itemCount = $derived(items.length);
const hasItems = $derived(items.length > 0);
// Complex derived
const sortedItems = $derived([...items].sort((a, b) => a.name.localeCompare(b.name)));
// Derived with conditions
const displayText = $derived(
count === 0 ? 'No items' : count === 1 ? '1 item' : `${count} items`
);
</script>
```
### Effects with $effect
```svelte
<script lang="ts">
import { browser } from '$app/environment';
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// Cleanup function
return () => clearTimeout(timer);
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
</script>
```
### Props with $props
```svelte
<script lang="ts">
import type { File } from '$lib/types';
// Define props with types
interface Props {
file: File;
selected?: boolean;
onDelete?: (id: string) => void;
onSelect?: (file: File) => void;
}
// Destructure with defaults
let { file, selected = false, onDelete, onSelect }: Props = $props();
function handleDelete() {
onDelete?.(file.id);
}
</script>
<div class:selected onclick={() => onSelect?.(file)}>
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
</div>
```
### Bindable Props with $bindable
```svelte
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<input bind:value {disabled} />
<!-- Usage: -->
<!-- <TextInput bind:value={searchQuery} /> -->
```
## Stores (Svelte 5 Pattern)
### Store File (.svelte.ts)
```typescript
// src/lib/stores/files.svelte.ts
import { browser } from '$app/environment';
import { api } from '$lib/api/client';
import type { File, AppError } from '$lib/types';
// Private state
let files = $state<File[]>([]);
let loading = $state(false);
let error = $state<AppError | null>(null);
let selectedId = $state<string | null>(null);
// Derived values
const selectedFile = $derived(files.find((f) => f.id === selectedId) ?? null);
const fileCount = $derived(files.length);
// Actions
async function loadFiles(folderId?: string): Promise<void> {
if (!browser) return;
loading = true;
error = null;
const result = await api.files.list(folderId);
if (result.ok) {
files = result.data;
} else {
error = result.error;
}
loading = false;
}
async function deleteFile(id: string): Promise<boolean> {
const result = await api.files.delete(id);
if (result.ok) {
files = files.filter((f) => f.id !== id);
if (selectedId === id) selectedId = null;
return true;
}
error = result.error;
return false;
}
function selectFile(id: string | null): void {
selectedId = id;
}
function reset(): void {
files = [];
loading = false;
error = null;
selectedId = null;
}
// Export as object with getters
export const fileStore = {
// Getters for state
get files() {
return files;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedFile() {
return selectedFile;
},
get fileCount() {
return fileCount;
},
// Actions
loadFiles,
deleteFile,
selectFile,
reset,
};
```
### Using Stores in Components
```svelte
<script lang="ts">
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
onMount(() => {
fileStore.loadFiles();
});
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
</script>
{#if fileStore.loading}
<LoadingSpinner />
{:else if fileStore.error}
<ErrorMessage message={fileStore.error.message} />
{:else}
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => fileStore.selectFile(file.id)}
onDelete={handleDelete}
/>
{/if}
```
## API Client
```typescript
// src/lib/api/client.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import type { Result, AppError } from '@manacore/shared-errors';
import { ErrorCode } from '@manacore/shared-errors';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
}
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
if (!browser) {
return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } };
}
try {
const token = authStore.token;
const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
// Handle 401 - redirect to login
if (response.status === 401) {
authStore.logout();
goto('/login');
return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } };
}
const json: ApiResponse<T> = await response.json();
if (!json.ok || json.error) {
return {
ok: false,
error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' },
};
}
return { ok: true, data: json.data as T };
} catch (error) {
return {
ok: false,
error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' },
};
}
}
// Typed API endpoints
export const api = {
files: {
list: (folderId?: string) =>
request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) => request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: UpdateFileDto) =>
request<File>(`/api/v1/files/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
delete: (id: string) => request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
},
folders: {
list: () => request<Folder[]>('/api/v1/folders'),
get: (id: string) => request<Folder>(`/api/v1/folders/${id}`),
create: (data: CreateFolderDto) =>
request<Folder>('/api/v1/folders', {
method: 'POST',
body: JSON.stringify(data),
}),
},
};
```
## Routing
### Route Groups
```
src/routes/
├── +layout.svelte # Root layout (applies to all)
├── +page.svelte # / (home)
├── (auth)/ # Auth pages (no sidebar)
│ ├── +layout.svelte # Auth layout
│ ├── login/+page.svelte
│ └── register/+page.svelte
└── (app)/ # App pages (with sidebar)
├── +layout.svelte # App layout with auth check
├── files/
│ ├── +page.svelte # /files
│ └── [id]/+page.svelte # /files/:id
└── settings/+page.svelte
```
### Layout with Auth Check
```svelte
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
let { children } = $props();
// Check auth on mount
$effect(() => {
if (browser && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.isAuthenticated}
<div class="flex h-screen">
<Sidebar />
<main class="flex-1 overflow-auto">
{@render children()}
</main>
</div>
{:else}
<div class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
{/if}
```
### Dynamic Routes
```svelte
<!-- src/routes/(app)/files/[id]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api/client';
let file = $state<File | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Load file when ID changes
$effect(() => {
const fileId = $page.params.id;
loadFile(fileId);
});
async function loadFile(id: string) {
loading = true;
error = null;
const result = await api.files.get(id);
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
loading = false;
}
</script>
{#if loading}
<LoadingSpinner />
{:else if error}
<ErrorMessage message={error} />
{:else if file}
<FileViewer {file} />
{/if}
```
## Components
### Component Pattern
```svelte
<!-- src/lib/components/files/FileCard.svelte -->
<script lang="ts">
import type { File } from '$lib/types';
import { formatBytes, formatDate } from '$lib/utils/format';
import FileIcon from './FileIcon.svelte';
interface Props {
file: File;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
let { file, selected = false, onSelect, onDelete }: Props = $props();
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
</script>
<div
class="p-4 rounded-lg border transition-colors cursor-pointer
{selected ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-gray-300'}"
onclick={onSelect}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onSelect?.()}
>
<div class="flex items-start gap-3">
<FileIcon mimeType={file.mimeType} />
<div class="flex-1 min-w-0">
<h3 class="font-medium truncate">{file.name}</h3>
<p class="text-sm text-gray-500">
{formattedSize} • {formattedDate}
</p>
</div>
{#if onDelete}
<button
class="p-2 text-gray-400 hover:text-red-500"
onclick|stopPropagation={onDelete}
aria-label="Delete file"
>
<TrashIcon />
</button>
{/if}
</div>
</div>
```
### Snippets (Slot Replacement)
```svelte
<!-- Parent component -->
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
let showModal = $state(false);
</script>
<Modal bind:open={showModal}>
{#snippet header()}
<h2>Confirm Delete</h2>
{/snippet}
{#snippet content()}
<p>Are you sure you want to delete this file?</p>
{/snippet}
{#snippet footer()}
<button onclick={() => showModal = false}>Cancel</button>
<button onclick={handleDelete}>Delete</button>
{/snippet}
</Modal>
<!-- Modal.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
open: boolean;
header?: Snippet;
content?: Snippet;
footer?: Snippet;
}
let { open = $bindable(), header, content, footer }: Props = $props();
</script>
{#if open}
<div class="modal-overlay" onclick={() => open = false}>
<div class="modal" onclick|stopPropagation>
{#if header}
<div class="modal-header">{@render header()}</div>
{/if}
{#if content}
<div class="modal-content">{@render content()}</div>
{/if}
{#if footer}
<div class="modal-footer">{@render footer()}</div>
{/if}
</div>
</div>
{/if}
```
## Styling
### Tailwind Configuration
```javascript
// tailwind.config.js
import sharedConfig from '@manacore/shared-tailwind';
export default {
presets: [sharedConfig],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
// Project-specific overrides
},
},
};
```
### Global Styles
```css
/* src/app.css */
@import 'tailwindcss';
@import '@manacore/shared-tailwind/theme.css';
/* Custom utilities */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
}
/* Custom components */
@layer components {
.btn-primary {
@apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors;
}
}
```
## Form Handling
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
errors = {};
// Client-side validation
if (!name.trim()) errors.name = 'Name is required';
if (!email.trim()) errors.email = 'Email is required';
if (Object.keys(errors).length > 0) return;
loading = true;
const result = await api.users.create({ name, email });
loading = false;
if (result.ok) {
goto('/users');
} else {
// Handle server errors
if (result.error.code === 'ERR_5002') {
errors.email = 'Email already exists';
} else {
errors.form = result.error.message;
}
}
}
</script>
<form onsubmit={handleSubmit}>
{#if errors.form}
<div class="text-red-500 mb-4">{errors.form}</div>
{/if}
<div class="mb-4">
<label for="name">Name</label>
<input id="name" bind:value={name} class:border-red-500={errors.name} />
{#if errors.name}
<span class="text-red-500 text-sm">{errors.name}</span>
{/if}
</div>
<div class="mb-4">
<label for="email">Email</label>
<input id="email" type="email" bind:value={email} class:border-red-500={errors.email} />
{#if errors.email}
<span class="text-red-500 text-sm">{errors.email}</span>
{/if}
</div>
<button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Saving...' : 'Save'}
</button>
</form>
```
## Environment Variables
```typescript
// Access in .svelte or .ts files
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
// .env file
PUBLIC_BACKEND_URL=http://localhost:3016
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Anti-Patterns to Avoid
### Don't Use Old Svelte Syntax
```svelte
<!-- BAD - Old Svelte 4 syntax -->
<script>
let count = 0;
$: doubled = count * 2;
$: console.log(count);
</script>
<!-- GOOD - Svelte 5 runes -->
<script>
let count = $state(0);
const doubled = $derived(count * 2);
$effect(() => console.log(count));
</script>
```
### Don't Create Stores in Components
```svelte
<!-- BAD - Store created in component -->
<script>
let store = $state({ items: [] }); // This is local, not shared
</script>
<!-- GOOD - Import store from .svelte.ts file -->
<script>
import { itemStore } from '$lib/stores/items.svelte';
</script>
```
### Don't Fetch in Render
```svelte
<!-- BAD - Fetches on every render -->
<script>
const promise = fetch('/api/data').then(r => r.json());
</script>
{#await promise}...{/await}
<!-- GOOD - Fetch in effect or onMount -->
<script>
import { onMount } from 'svelte';
let data = $state(null);
onMount(async () => {
data = await fetch('/api/data').then(r => r.json());
});
</script>
```

View file

@ -0,0 +1,577 @@
# Testing Guidelines
## Overview
| App Type | Framework | Config | File Pattern |
|----------|-----------|--------|--------------|
| **NestJS Backend** | Jest + ts-jest | `jest.config.js` | `*.spec.ts` |
| **Expo Mobile** | Jest + jest-expo | `jest.config.js` | `*.test.tsx` |
| **SvelteKit Web** | Vitest | `vitest.config.ts` | `*.test.ts` |
| **E2E** | Playwright | `playwright.config.ts` | `e2e/*.spec.ts` |
## Coverage Requirements
```javascript
// Target: 80% for all new code
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
}
```
## Test File Organization
```
src/
├── __tests__/
│ ├── utils/
│ │ ├── mock-factories.ts # Centralized factories
│ │ └── test-helpers.ts # Shared utilities
│ └── fixtures/ # Test data files
├── feature/
│ ├── feature.service.ts
│ └── feature.service.spec.ts # Colocated test
└── ...
```
## Mock Factories Pattern
Create reusable factories for test data:
```typescript
// src/__tests__/utils/mock-factories.ts
import { nanoid } from 'nanoid';
export const mockUserFactory = {
create: (overrides: Partial<User> = {}): User => ({
id: nanoid(),
email: `test-${nanoid(6)}@example.com`,
name: 'Test User',
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
createMany: (count: number, overrides: Partial<User> = {}): User[] => {
return Array.from({ length: count }, () => mockUserFactory.create(overrides));
},
};
export const mockFileFactory = {
create: (overrides: Partial<File> = {}): File => ({
id: nanoid(),
userId: nanoid(),
name: `file-${nanoid(6)}.txt`,
mimeType: 'text/plain',
size: 1024,
storagePath: `/files/${nanoid()}`,
storageKey: nanoid(),
isDeleted: false,
isFavorite: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
createMany: (count: number, overrides: Partial<File> = {}): File[] => {
return Array.from({ length: count }, () => mockFileFactory.create(overrides));
},
};
// Usage in tests:
const user = mockUserFactory.create({ role: 'admin' });
const files = mockFileFactory.createMany(5, { userId: user.id });
```
## Test Helpers
```typescript
// src/__tests__/utils/test-helpers.ts
import { ConfigService } from '@nestjs/config';
// Mock config service
export function createMockConfigService(overrides: Record<string, any> = {}) {
const config: Record<string, any> = {
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
MANA_CORE_AUTH_URL: 'http://localhost:3001',
...overrides,
};
return {
get: jest.fn((key: string) => config[key]),
getOrThrow: jest.fn((key: string) => {
if (!(key in config)) throw new Error(`Missing config: ${key}`);
return config[key];
}),
} as unknown as ConfigService;
}
// Mock database with chainable methods
export function createMockDb() {
const results: any[] = [];
let resultIndex = 0;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
transaction: jest.fn(),
// Thenable for await
then: jest.fn((resolve) => resolve(results[resultIndex++] || [])),
// Helper to set results
mockResults: (...newResults: any[]) => {
results.length = 0;
results.push(...newResults);
resultIndex = 0;
},
// Reset all mocks
reset: () => {
jest.clearAllMocks();
results.length = 0;
resultIndex = 0;
},
};
return mockDb;
}
// Assertion helpers
export const assertHelpers = {
assertIsUuid: (value: string) => {
expect(value).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
},
assertIsRecent: (date: Date, toleranceMs = 5000) => {
const now = Date.now();
expect(date.getTime()).toBeGreaterThan(now - toleranceMs);
expect(date.getTime()).toBeLessThanOrEqual(now);
},
assertResultOk: <T>(result: Result<T>): T => {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Expected ok result');
return result.data;
},
assertResultErr: (result: Result<any>, expectedCode?: ErrorCode) => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error('Expected error result');
if (expectedCode) {
expect(result.error.code).toBe(expectedCode);
}
return result.error;
},
};
```
## NestJS Unit Tests
### Service Tests
```typescript
// src/files/file.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from './file.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { mockFileFactory, createMockDb, assertHelpers } from '../__tests__/utils';
import { ErrorCode } from '@manacore/shared-errors';
describe('FileService', () => {
let service: FileService;
let mockDb: ReturnType<typeof createMockDb>;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{ provide: DATABASE_CONNECTION, useValue: mockDb },
],
}).compile();
service = module.get<FileService>(FileService);
});
afterEach(() => {
mockDb.reset();
});
describe('findById', () => {
it('should return file when found', async () => {
const mockFile = mockFileFactory.create();
mockDb.mockResults([mockFile]);
const result = await service.findById(mockFile.id, mockFile.userId);
const file = assertHelpers.assertResultOk(result);
expect(file.id).toBe(mockFile.id);
expect(mockDb.select).toHaveBeenCalled();
});
it('should return NOT_FOUND error when file does not exist', async () => {
mockDb.mockResults([]);
const result = await service.findById('non-existent', 'user-123');
const error = assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND);
expect(error.message).toContain('not found');
});
it('should not return files belonging to other users', async () => {
mockDb.mockResults([]); // Query returns empty due to userId filter
const result = await service.findById('file-123', 'different-user');
assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND);
});
});
describe('create', () => {
it('should create and return new file', async () => {
const userId = 'user-123';
const dto = {
name: 'test.txt',
mimeType: 'text/plain',
size: 1024,
storagePath: '/files/test.txt',
storageKey: 'key-123',
};
const createdFile = mockFileFactory.create({ ...dto, userId });
mockDb.mockResults([createdFile]);
const result = await service.create(userId, dto);
const file = assertHelpers.assertResultOk(result);
expect(file.name).toBe(dto.name);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should return validation error for empty name', async () => {
const result = await service.create('user-123', {
name: '',
mimeType: 'text/plain',
size: 100,
storagePath: '/test',
storageKey: 'key',
});
assertHelpers.assertResultErr(result, ErrorCode.MISSING_REQUIRED_FIELD);
});
});
});
```
### Controller Tests
```typescript
// src/files/file.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { FileController } from './file.controller';
import { FileService } from './file.service';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { mockFileFactory } from '../__tests__/utils';
import { ok, err, ErrorCode, AppException } from '@manacore/shared-errors';
describe('FileController', () => {
let controller: FileController;
let fileService: jest.Mocked<FileService>;
const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FileController],
providers: [
{
provide: FileService,
useValue: {
findById: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FileController>(FileController);
fileService = module.get(FileService) as jest.Mocked<FileService>;
});
describe('GET /files/:id', () => {
it('should return file when found', async () => {
const mockFile = mockFileFactory.create();
fileService.findById.mockResolvedValue(ok(mockFile));
const result = await controller.getFile(mockFile.id, mockUser);
expect(result.file).toEqual(mockFile);
expect(fileService.findById).toHaveBeenCalledWith(mockFile.id, mockUser.userId);
});
it('should throw AppException when file not found', async () => {
fileService.findById.mockResolvedValue(
err(ErrorCode.FILE_NOT_FOUND, 'File not found')
);
await expect(controller.getFile('non-existent', mockUser))
.rejects
.toThrow(AppException);
});
});
describe('Guards', () => {
it('should have JwtAuthGuard applied', () => {
const guards = Reflect.getMetadata('__guards__', FileController);
expect(guards).toContain(JwtAuthGuard);
});
});
});
```
## Vitest (SvelteKit) Tests
### Store Tests
```typescript
// src/lib/stores/files.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fileStore } from './files.svelte';
import { api } from '$lib/api/client';
vi.mock('$lib/api/client', () => ({
api: {
files: {
list: vi.fn(),
delete: vi.fn(),
},
},
}));
describe('fileStore', () => {
beforeEach(() => {
vi.clearAllMocks();
fileStore.reset();
});
it('should load files successfully', async () => {
const mockFiles = [
{ id: '1', name: 'file1.txt' },
{ id: '2', name: 'file2.txt' },
];
vi.mocked(api.files.list).mockResolvedValue({ ok: true, data: mockFiles });
await fileStore.loadFiles();
expect(fileStore.files).toEqual(mockFiles);
expect(fileStore.loading).toBe(false);
expect(fileStore.error).toBeNull();
});
it('should handle load error', async () => {
vi.mocked(api.files.list).mockResolvedValue({
ok: false,
error: { code: 'ERR_7001', message: 'Database error' },
});
await fileStore.loadFiles();
expect(fileStore.files).toEqual([]);
expect(fileStore.error).toBe('Database error');
});
it('should remove file from list after delete', async () => {
fileStore.files = [
{ id: '1', name: 'file1.txt' },
{ id: '2', name: 'file2.txt' },
];
vi.mocked(api.files.delete).mockResolvedValue({ ok: true, data: undefined });
await fileStore.deleteFile('1');
expect(fileStore.files).toHaveLength(1);
expect(fileStore.files[0].id).toBe('2');
});
});
```
### Component Tests
```typescript
// src/lib/components/FileItem.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FileItem from './FileItem.svelte';
describe('FileItem', () => {
const mockFile = {
id: '1',
name: 'document.pdf',
size: 1024,
mimeType: 'application/pdf',
createdAt: new Date('2024-01-01'),
};
it('should render file name', () => {
render(FileItem, { props: { file: mockFile } });
expect(screen.getByText('document.pdf')).toBeInTheDocument();
});
it('should format file size', () => {
render(FileItem, { props: { file: mockFile } });
expect(screen.getByText('1 KB')).toBeInTheDocument();
});
it('should call onDelete when delete button clicked', async () => {
const onDelete = vi.fn();
render(FileItem, { props: { file: mockFile, onDelete } });
const deleteButton = screen.getByRole('button', { name: /delete/i });
await fireEvent.click(deleteButton);
expect(onDelete).toHaveBeenCalledWith(mockFile.id);
});
});
```
## E2E Tests (Playwright)
### Configuration
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 5173,
reuseExistingServer: !process.env.CI,
},
});
```
### E2E Test Example
```typescript
// e2e/file-upload.spec.ts
import { test, expect } from '@playwright/test';
test.describe('File Upload', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/files');
});
test('should upload a file successfully', async ({ page }) => {
// Open upload dialog
await page.click('button:has-text("Upload")');
// Select file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles('./e2e/fixtures/test-file.txt');
// Wait for upload
await expect(page.getByText('test-file.txt')).toBeVisible();
await page.click('button:has-text("Upload")');
// Verify file appears in list
await expect(page.getByRole('listitem', { name: 'test-file.txt' })).toBeVisible();
});
test('should show error for oversized file', async ({ page }) => {
await page.click('button:has-text("Upload")');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles('./e2e/fixtures/large-file.zip');
await expect(page.getByText(/file too large/i)).toBeVisible();
});
});
```
## Running Tests
```bash
# Run all tests
pnpm test
# Run with coverage
pnpm test:cov
# Run specific project
pnpm --filter @storage/backend test
# Run in watch mode
pnpm test:watch
# Run E2E tests
pnpm test:e2e
# Run E2E in headed mode
pnpm test:e2e --headed
```
## Best Practices
### Do's
1. **Use factories** for consistent test data
2. **Test behavior, not implementation**
3. **One assertion per test** when possible
4. **Clean up** after each test (reset mocks, state)
5. **Use descriptive test names** that explain expected behavior
### Don'ts
1. **Don't test framework code** - trust NestJS, Svelte, etc.
2. **Don't mock everything** - integration tests are valuable
3. **Don't test private methods** - test through public API
4. **Don't share state between tests** - each test should be independent
5. **Don't write flaky tests** - fix or remove them

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
pnpm exec lint-staged
pnpm run type-check

View file

@ -10,6 +10,24 @@ This is a pnpm workspace monorepo containing multiple product applications with
**Build System:** Turborepo
**Node Version:** 20+
## Detailed Guidelines
For comprehensive guidelines on code patterns and conventions, see the `.claude/` directory:
| Document | Purpose |
|----------|---------|
| [`.claude/GUIDELINES.md`](.claude/GUIDELINES.md) | Main reference overview |
| [`.claude/guidelines/code-style.md`](.claude/guidelines/code-style.md) | Formatting, naming, linting |
| [`.claude/guidelines/database.md`](.claude/guidelines/database.md) | Drizzle ORM, schema patterns |
| [`.claude/guidelines/testing.md`](.claude/guidelines/testing.md) | Jest/Vitest, mock factories |
| [`.claude/guidelines/nestjs-backend.md`](.claude/guidelines/nestjs-backend.md) | Controllers, services, DTOs |
| [`.claude/guidelines/error-handling.md`](.claude/guidelines/error-handling.md) | Go-style Result types, error codes |
| [`.claude/guidelines/sveltekit-web.md`](.claude/guidelines/sveltekit-web.md) | Svelte 5 runes, stores |
| [`.claude/guidelines/expo-mobile.md`](.claude/guidelines/expo-mobile.md) | React Native, NativeWind |
| [`.claude/guidelines/authentication.md`](.claude/guidelines/authentication.md) | Mana Core Auth integration |
**Always consult these guidelines before making changes.**
## Projects
| Project | Description | Apps |

View file

@ -4,7 +4,6 @@
pnpm docker:up:all
pnpm docker:down
pnpm dev:chat:app
@ -17,26 +16,30 @@ pnpm dev:zitare:app
pnpm dev:presi:app
# Deployment Landingpages:
## Einzelne Landing Page
pnpm deploy:landing:chat
pnpm deploy:landing:picture
pnpm deploy:landing:manacore
pnpm deploy:landing:manadeck
pnpm deploy:landing:zitare
Hier sind alle Landing Page URLs:
## Einzelne Landing Page
pnpm deploy:landing:chat
pnpm deploy:landing:picture
pnpm deploy:landing:manacore
pnpm deploy:landing:manadeck
pnpm deploy:landing:zitare
Hier sind alle Landing Page URLs:
| Projekt | URL |
|----------|------------------------------------|
| Chat | https://chat-landing-90m.pages.dev |
| Picture | https://picture-landing.pages.dev |
| ManaCore | https://manacore-landing.pages.dev |
| ManaDeck | https://manadeck-landing.pages.dev |
| Zitare | https://zitare-landing.pages.dev |
| Presi | https://presi-landing.pages.dev |
## Alle auf einmal
pnpm deploy:landing:all
|----------|------------------------------------|
| Chat | https://chat-landing-90m.pages.dev |
| Picture | https://picture-landing.pages.dev |
| ManaCore | https://manacore-landing.pages.dev |
| ManaDeck | https://manadeck-landing.pages.dev |
| Zitare | https://zitare-landing.pages.dev |
| Presi | https://presi-landing.pages.dev |
## Alle auf einmal
pnpm deploy:landing:all
Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps.

View file

@ -1,13 +1,4 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { CalendarService } from './calendar.service';
import { CreateCalendarDto, UpdateCalendarDto } from './dto';

View file

@ -81,9 +81,7 @@ export class CalendarService {
}
}
await this.db
.delete(calendars)
.where(and(eq(calendars.id, id), eq(calendars.userId, userId)));
await this.db.delete(calendars).where(and(eq(calendars.id, id), eq(calendars.userId, userId)));
}
async getOrCreateDefaultCalendar(userId: string): Promise<Calendar> {

View file

@ -1,4 +1,13 @@
import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, index } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { calendars } from './calendars.schema';
/**

View file

@ -1,4 +1,13 @@
import { pgTable, uuid, timestamp, varchar, text, boolean, jsonb, integer } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
jsonb,
integer,
} from 'drizzle-orm/pg-core';
/**
* Provider-specific metadata

View file

@ -1,14 +1,4 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { EventService } from './event.service';
import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto';

View file

@ -32,9 +32,7 @@ export class EventService {
// Exclude cancelled unless requested
if (!query.includeCancelled) {
conditions.push(
or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any
);
conditions.push(or(eq(events.status, 'confirmed'), eq(events.status, 'tentative')) as any);
}
// Search filter

View file

@ -1,12 +1,4 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ReminderService } from './reminder.service';
import { CreateReminderDto } from './dto';
@ -17,10 +9,7 @@ export class ReminderController {
constructor(private readonly reminderService: ReminderService) {}
@Get('events/:eventId/reminders')
async findByEvent(
@CurrentUser() user: CurrentUserData,
@Param('eventId') eventId: string
) {
async findByEvent(@CurrentUser() user: CurrentUserData, @Param('eventId') eventId: string) {
const reminders = await this.reminderService.findByEvent(eventId, user.userId);
return { reminders };
}

View file

@ -61,9 +61,7 @@ export class ReminderService {
throw new NotFoundException(`Reminder with id ${id} not found`);
}
await this.db
.delete(reminders)
.where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
await this.db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
}
async getPendingReminders(): Promise<Reminder[]> {
@ -74,9 +72,7 @@ export class ReminderService {
return this.db
.select()
.from(reminders)
.where(
and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow))
);
.where(and(eq(reminders.status, 'pending'), lte(reminders.reminderTime, oneMinuteFromNow)));
}
async markAsSent(id: string): Promise<void> {
@ -116,7 +112,9 @@ export class ReminderService {
// TODO: Implement actual notification sending
// For now, just log and mark as sent
console.log(`[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes`);
console.log(
`[Reminder] Event "${event.title}" starting in ${reminder.minutesBefore} minutes`
);
if (reminder.notifyPush) {
// TODO: Send push notification via Expo Push API
@ -145,9 +143,7 @@ export class ReminderService {
.where(and(eq(reminders.eventId, eventId), eq(reminders.status, 'pending')));
for (const reminder of eventReminders) {
const newReminderTime = new Date(
newStartTime.getTime() - reminder.minutesBefore * 60 * 1000
);
const newReminderTime = new Date(newStartTime.getTime() - reminder.minutesBefore * 60 * 1000);
await this.db
.update(reminders)

View file

@ -1,4 +1,12 @@
import { IsString, IsOptional, IsBoolean, IsIn, IsEmail, IsDateString, IsUUID } from 'class-validator';
import {
IsString,
IsOptional,
IsBoolean,
IsIn,
IsEmail,
IsDateString,
IsUUID,
} from 'class-validator';
export class CreateShareDto {
@IsUUID()

View file

@ -1,13 +1,4 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ShareService } from './share.service';
import { CreateShareDto, UpdateShareDto } from './dto';
@ -50,10 +41,7 @@ export class ShareController {
}
@Delete('calendars/:calendarId/shares/:shareId')
async delete(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string
) {
async delete(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) {
await this.shareService.delete(shareId, user.userId);
return { success: true };
}
@ -69,19 +57,13 @@ export class ShareController {
}
@Post('shares/:shareId/accept')
async acceptInvitation(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string
) {
async acceptInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) {
const share = await this.shareService.acceptInvitation(shareId, user.userId);
return { share };
}
@Post('shares/:shareId/decline')
async declineInvitation(
@CurrentUser() user: CurrentUserData,
@Param('shareId') shareId: string
) {
async declineInvitation(@CurrentUser() user: CurrentUserData, @Param('shareId') shareId: string) {
const share = await this.shareService.declineInvitation(shareId, user.userId);
return { share };
}

View file

@ -22,17 +22,11 @@ export class ShareService {
// Verify user owns the calendar
await this.calendarService.findByIdOrThrow(calendarId, userId);
return this.db
.select()
.from(calendarShares)
.where(eq(calendarShares.calendarId, calendarId));
return this.db.select().from(calendarShares).where(eq(calendarShares.calendarId, calendarId));
}
async findById(id: string): Promise<CalendarShare | null> {
const result = await this.db
.select()
.from(calendarShares)
.where(eq(calendarShares.id, id));
const result = await this.db.select().from(calendarShares).where(eq(calendarShares.id, id));
return result[0] || null;
}
@ -43,10 +37,7 @@ export class ShareService {
.where(
and(
eq(calendarShares.status, 'pending'),
or(
eq(calendarShares.sharedWithUserId, userId),
eq(calendarShares.sharedWithEmail, email)
)
or(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.sharedWithEmail, email))
)
);
}
@ -174,10 +165,7 @@ export class ShareService {
.select()
.from(calendarShares)
.where(
and(
eq(calendarShares.sharedWithUserId, userId),
eq(calendarShares.status, 'accepted')
)
and(eq(calendarShares.sharedWithUserId, userId), eq(calendarShares.status, 'accepted'))
);
}
}

View file

@ -4,7 +4,8 @@
<section class="relative overflow-hidden bg-dark-bg">
<!-- Background gradient -->
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/30 via-dark-bg to-primary-950/30"></div>
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/30 via-dark-bg to-primary-950/30">
</div>
<div class="container relative">
<div class="mx-auto max-w-3xl text-center">
@ -12,39 +13,44 @@
Bereit, deine Zeit zu organisieren?
</h2>
<p class="mb-10 text-lg text-gray-400">
Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann.
Keine Kreditkarte erforderlich.
Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann. Keine Kreditkarte
erforderlich.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a href="#" class="btn btn-primary text-lg">
Jetzt kostenlos starten
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a href="#features" class="btn btn-secondary">
Mehr erfahren
</a>
<a href="#features" class="btn btn-secondary"> Mehr erfahren </a>
</div>
<!-- Benefits list -->
<div class="mt-12 flex flex-wrap items-center justify-center gap-6 text-sm text-gray-500">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<span>Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<span>Keine Kreditkarte</span>
</div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
></path>
</svg>
<span>Jederzeit kündbar</span>
</div>

View file

@ -7,43 +7,49 @@ const features = [
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>`,
title: 'Mehrere Kalender',
description: 'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.'
description:
'Verwalte verschiedene Kalender für Arbeit, Privates, Familie und mehr - alles übersichtlich farbcodiert.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>`,
title: 'Kalender teilen',
description: 'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.'
description:
'Teile Kalender mit Familie, Freunden oder Kollegen. Vergib Lese- oder Bearbeitungsrechte.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>`,
title: 'CalDAV & iCal Sync',
description: 'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.'
description:
'Synchronisiere mit Google Calendar, Apple Calendar, Outlook und jedem CalDAV-kompatiblen Dienst.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>`,
title: 'Smarte Erinnerungen',
description: 'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.'
description:
'Nie wieder einen Termin verpassen. Push-Benachrichtigungen und E-Mail-Erinnerungen zur rechten Zeit.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>`,
title: 'Wiederkehrende Termine',
description: 'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.'
description:
'Erstelle einmalige oder wiederkehrende Termine mit flexiblen Wiederholungsregeln nach RFC 5545.',
},
{
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>`,
title: 'Mobile & Desktop',
description: 'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.'
}
description:
'Greife von überall auf deine Termine zu - Web-App, iOS und Android mit Offline-Support.',
},
];
---
@ -54,9 +60,7 @@ const features = [
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Funktionen
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">
Alles was du brauchst
</h2>
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">Alles was du brauchst</h2>
<p class="text-lg text-gray-400">
Kalender bietet alle Funktionen, die du für effektives Zeitmanagement benötigst.
</p>
@ -64,15 +68,17 @@ const features = [
<!-- Features grid -->
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => (
<div class="group rounded-xl border border-dark-border bg-dark-card p-6 transition-all duration-300 hover:border-primary-500/50 hover:bg-dark-card/80">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-500/10 text-primary-400 transition-colors group-hover:bg-primary-500/20">
<Fragment set:html={feature.icon} />
{
features.map((feature) => (
<div class="group rounded-xl border border-dark-border bg-dark-card p-6 transition-all duration-300 hover:border-primary-500/50 hover:bg-dark-card/80">
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-500/10 text-primary-400 transition-colors group-hover:bg-primary-500/20">
<Fragment set:html={feature.icon} />
</div>
<h3 class="mb-3 text-xl font-semibold">{feature.title}</h3>
<p class="text-gray-400">{feature.description}</p>
</div>
<h3 class="mb-3 text-xl font-semibold">{feature.title}</h3>
<p class="text-gray-400">{feature.description}</p>
</div>
))}
))
}
</div>
</div>
</section>

View file

@ -8,18 +8,18 @@ const links = {
{ name: 'Funktionen', href: '#features' },
{ name: 'Preise', href: '#pricing' },
{ name: 'Changelog', href: '/changelog' },
{ name: 'Roadmap', href: '/roadmap' }
{ name: 'Roadmap', href: '/roadmap' },
],
legal: [
{ name: 'Impressum', href: '/impressum' },
{ name: 'Datenschutz', href: '/datenschutz' },
{ name: 'AGB', href: '/agb' }
{ name: 'AGB', href: '/agb' },
],
support: [
{ name: 'FAQ', href: '/faq' },
{ name: 'Kontakt', href: '/kontakt' },
{ name: 'Status', href: '/status' }
]
{ name: 'Status', href: '/status' },
],
};
---
@ -29,53 +29,75 @@ const links = {
<!-- Brand -->
<div class="md:col-span-1">
<div class="mb-4 flex items-center gap-2">
<svg class="h-8 w-8 text-primary-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<svg
class="h-8 w-8 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
<span class="text-xl font-bold">Kalender</span>
</div>
<p class="text-sm text-gray-500">
Smart Calendar Management für besseres Zeitmanagement.
</p>
<p class="text-sm text-gray-500">Smart Calendar Management für besseres Zeitmanagement.</p>
</div>
<!-- Links -->
<div>
<h4 class="mb-4 font-semibold">Produkt</h4>
<ul class="space-y-2 text-sm text-gray-400">
{links.product.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">{link.name}</a>
</li>
))}
{
links.product.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">
{link.name}
</a>
</li>
))
}
</ul>
</div>
<div>
<h4 class="mb-4 font-semibold">Rechtliches</h4>
<ul class="space-y-2 text-sm text-gray-400">
{links.legal.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">{link.name}</a>
</li>
))}
{
links.legal.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">
{link.name}
</a>
</li>
))
}
</ul>
</div>
<div>
<h4 class="mb-4 font-semibold">Support</h4>
<ul class="space-y-2 text-sm text-gray-400">
{links.support.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">{link.name}</a>
</li>
))}
{
links.support.map((link) => (
<li>
<a href={link.href} class="transition-colors hover:text-white">
{link.name}
</a>
</li>
))
}
</ul>
</div>
</div>
<!-- Bottom bar -->
<div class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row">
<div
class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row"
>
<p class="text-sm text-gray-500">
&copy; {currentYear} Kalender. Alle Rechte vorbehalten.
</p>

View file

@ -4,23 +4,24 @@
<section class="relative overflow-hidden py-20 md:py-32">
<!-- Background gradient -->
<div
class="absolute inset-0 bg-gradient-to-b from-primary-950/30 via-dark-bg to-dark-bg"
>
</div>
<div class="absolute inset-0 bg-gradient-to-b from-primary-950/30 via-dark-bg to-dark-bg"></div>
<!-- Grid pattern -->
<div
class="absolute inset-0 bg-[url('/grid.svg')] bg-center opacity-10"
>
</div>
<div class="absolute inset-0 bg-[url('/grid.svg')] bg-center opacity-10"></div>
<div class="container relative">
<div class="mx-auto max-w-4xl text-center">
<!-- Badge -->
<div class="mb-8 inline-flex items-center gap-2 rounded-full border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-sm text-primary-400">
<div
class="mb-8 inline-flex items-center gap-2 rounded-full border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-sm text-primary-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
<span>Smart Kalender-Management</span>
</div>
@ -33,34 +34,38 @@
<!-- Subheadline -->
<p class="mx-auto mb-10 max-w-2xl text-lg text-gray-400 md:text-xl">
Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen - alles an einem Ort. Behalte den Überblick über dein Leben.
Persönliche Kalender, geteilte Termine, CalDAV-Synchronisation und smarte Erinnerungen -
alles an einem Ort. Behalte den Überblick über dein Leben.
</p>
<!-- CTA Buttons -->
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="#"
class="btn btn-primary group text-lg"
>
<a href="#" class="btn btn-primary group text-lg">
Kostenlos starten
<svg class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
<svg
class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
</svg>
</a>
<a
href="#features"
class="btn btn-secondary"
>
Funktionen entdecken
</a>
<a href="#features" class="btn btn-secondary"> Funktionen entdecken </a>
</div>
<!-- Social proof -->
<div class="mt-16 flex flex-col items-center gap-4">
<div class="flex -space-x-2">
{[1, 2, 3, 4, 5].map((i) => (
<div class="h-10 w-10 rounded-full border-2 border-dark-bg bg-gradient-to-br from-primary-400 to-primary-600"></div>
))}
{
[1, 2, 3, 4, 5].map((i) => (
<div class="h-10 w-10 rounded-full border-2 border-dark-bg bg-gradient-to-br from-primary-400 to-primary-600" />
))
}
</div>
<p class="text-sm text-gray-500">
<span class="font-semibold text-white">500+</span> Nutzer vertrauen Kalender
@ -70,7 +75,10 @@
<!-- Preview mockup -->
<div class="relative mx-auto mt-16 max-w-5xl">
<div class="absolute -inset-4 rounded-2xl bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"></div>
<div
class="absolute -inset-4 rounded-2xl bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"
>
</div>
<div class="relative rounded-xl border border-dark-border bg-dark-card p-2 shadow-2xl">
<div class="flex gap-2 px-4 py-3">
<div class="h-3 w-3 rounded-full bg-red-500"></div>
@ -89,14 +97,20 @@
</div>
</div>
<div class="grid grid-cols-7 gap-2">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => (
<div class="text-center text-sm text-gray-500">{day}</div>
))}
{Array.from({ length: 35 }, (_, i) => (
<div class={`rounded-lg p-2 text-center text-sm ${i === 14 ? 'bg-primary-500 text-white' : 'bg-dark-card'}`}>
{((i % 31) + 1).toString()}
</div>
))}
{
['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map((day) => (
<div class="text-center text-sm text-gray-500">{day}</div>
))
}
{
Array.from({ length: 35 }, (_, i) => (
<div
class={`rounded-lg p-2 text-center text-sm ${i === 14 ? 'bg-primary-500 text-white' : 'bg-dark-card'}`}
>
{((i % 31) + 1).toString()}
</div>
))
}
</div>
</div>
</div>

View file

@ -114,104 +114,139 @@ const pricingPlans = [
<Hero />
<Features />
{StepsSection && (
<StepsSection
id="how-it-works"
title="So einfach geht's"
subtitle="In drei Schritten zum organisierten Leben"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-dark-surface"
/>
)}
{
StepsSection && (
<StepsSection
id="how-it-works"
title="So einfach geht's"
subtitle="In drei Schritten zum organisierten Leben"
steps={steps}
showImages={false}
alternateLayout={true}
class="bg-dark-surface"
/>
)
}
{!StepsSection && (
<section id="how-it-works" class="bg-dark-surface">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
So funktioniert's
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">So einfach geht's</h2>
<p class="text-lg text-gray-400">In drei Schritten zum organisierten Leben</p>
</div>
{
!StepsSection && (
<section id="how-it-works" class="bg-dark-surface">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
So funktioniert's
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">So einfach geht's</h2>
<p class="text-lg text-gray-400">In drei Schritten zum organisierten Leben</p>
</div>
<div class="grid gap-8 md:grid-cols-3">
{steps.map((step) => (
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-500/20 text-2xl font-bold text-primary-400">
{step.number}
</div>
<h3 class="mb-3 text-xl font-semibold">{step.title}</h3>
<p class="text-gray-400">{step.description}</p>
</div>
))}
</div>
</div>
</section>
)}
{PricingSection && (
<PricingSection
id="pricing"
title="Einfache, transparente Preise"
subtitle="Starte kostenlos, upgrade wenn du mehr brauchst"
plans={pricingPlans}
class="bg-dark-bg"
/>
)}
{!PricingSection && (
<section id="pricing" class="bg-dark-bg">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Preise
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">Einfache, transparente Preise</h2>
<p class="text-lg text-gray-400">Starte kostenlos, upgrade wenn du mehr brauchst</p>
</div>
<div class="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
{pricingPlans.map((plan) => (
<div class={`relative rounded-xl border p-6 ${plan.highlighted ? 'border-primary-500 bg-primary-500/10' : 'border-dark-border bg-dark-card'}`}>
{plan.badge && (
<div class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-medium text-white">
{plan.badge}
<div class="grid gap-8 md:grid-cols-3">
{steps.map((step) => (
<div class="text-center">
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary-500/20 text-2xl font-bold text-primary-400">
{step.number}
</div>
)}
<h3 class="mb-2 text-xl font-semibold">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-400">{plan.description}</p>
<div class="mb-6">
<span class="text-4xl font-bold">{plan.price}€</span>
<span class="text-gray-500">{plan.period}</span>
<h3 class="mb-3 text-xl font-semibold">{step.title}</h3>
<p class="text-gray-400">{step.description}</p>
</div>
<ul class="mb-8 space-y-3">
{plan.features.map((feature) => (
<li class={`flex items-center gap-2 text-sm ${feature.included ? 'text-white' : 'text-gray-600'}`}>
{feature.included ? (
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
) : (
<svg class="h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
)}
{feature.text}
</li>
))}
</ul>
<a href={plan.cta.href} class={`btn w-full ${plan.highlighted ? 'btn-primary' : 'btn-secondary'}`}>
{plan.cta.text}
</a>
</div>
))}
))}
</div>
</div>
</div>
</section>
)}
</section>
)
}
{
PricingSection && (
<PricingSection
id="pricing"
title="Einfache, transparente Preise"
subtitle="Starte kostenlos, upgrade wenn du mehr brauchst"
plans={pricingPlans}
class="bg-dark-bg"
/>
)
}
{
!PricingSection && (
<section id="pricing" class="bg-dark-bg">
<div class="container">
<div class="mx-auto mb-16 max-w-3xl text-center">
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
Preise
</span>
<h2 class="mb-6 text-3xl font-bold md:text-4xl">Einfache, transparente Preise</h2>
<p class="text-lg text-gray-400">Starte kostenlos, upgrade wenn du mehr brauchst</p>
</div>
<div class="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
{pricingPlans.map((plan) => (
<div
class={`relative rounded-xl border p-6 ${plan.highlighted ? 'border-primary-500 bg-primary-500/10' : 'border-dark-border bg-dark-card'}`}
>
{plan.badge && (
<div class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-medium text-white">
{plan.badge}
</div>
)}
<h3 class="mb-2 text-xl font-semibold">{plan.name}</h3>
<p class="mb-4 text-sm text-gray-400">{plan.description}</p>
<div class="mb-6">
<span class="text-4xl font-bold">{plan.price}€</span>
<span class="text-gray-500">{plan.period}</span>
</div>
<ul class="mb-8 space-y-3">
{plan.features.map((feature) => (
<li
class={`flex items-center gap-2 text-sm ${feature.included ? 'text-white' : 'text-gray-600'}`}
>
{feature.included ? (
<svg
class="h-5 w-5 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg
class="h-5 w-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
)}
{feature.text}
</li>
))}
</ul>
<a
href={plan.cta.href}
class={`btn w-full ${plan.highlighted ? 'btn-primary' : 'btn-secondary'}`}
>
{plan.cta.text}
</a>
</div>
))}
</div>
</div>
</section>
)
}
<CTA />
<Footer />

View file

@ -417,8 +417,14 @@
></div>
<span class="event-time">
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
</span>
<span class="event-title">{event.title}</span>
{#if event.location}

View file

@ -235,17 +235,21 @@
tabindex="0"
>
{#if !event.isAllDay}
<span class="event-time">{format(typeof event.startTime === 'string' ? new Date(event.startTime) : event.startTime, 'HH:mm')}</span>
<span class="event-time"
>{format(
typeof event.startTime === 'string'
? new Date(event.startTime)
: event.startTime,
'HH:mm'
)}</span
>
{/if}
<span class="event-title">{event.title}</span>
</div>
{/each}
{#if eventsStore.getEventsForDay(day).length > 3}
<button
class="more-events"
onclick={(e) => handleMoreClick(day, e)}
>
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
+{eventsStore.getEventsForDay(day).length - 3} mehr
</button>
{/if}

View file

@ -40,7 +40,8 @@
// Initialize date/time fields using settings for default duration
$effect(() => {
if (event) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
startDate = format(start, 'yyyy-MM-dd');
startTime = format(start, 'HH:mm');
@ -112,7 +113,11 @@
<div class="flex flex-col gap-2">
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
<select id="calendar" class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors" bind:value={calendarId}>
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
@ -144,12 +149,24 @@
<div class="flex gap-4">
<div class="flex-1 flex flex-col gap-2">
<label for="startDate" class="text-sm font-medium text-foreground">Beginn</label>
<input type="date" id="startDate" class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors" bind:value={startDate} required />
<input
type="date"
id="startDate"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={startDate}
required
/>
</div>
{#if !isAllDay}
<div class="flex-1 flex flex-col gap-2">
<label for="startTime" class="text-sm font-medium text-foreground">Uhrzeit</label>
<input type="time" id="startTime" class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors" bind:value={startTime} required />
<input
type="time"
id="startTime"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={startTime}
required
/>
</div>
{/if}
</div>
@ -157,12 +174,24 @@
<div class="flex gap-4">
<div class="flex-1 flex flex-col gap-2">
<label for="endDate" class="text-sm font-medium text-foreground">Ende</label>
<input type="date" id="endDate" class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors" bind:value={endDate} required />
<input
type="date"
id="endDate"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={endDate}
required
/>
</div>
{#if !isAllDay}
<div class="flex-1 flex flex-col gap-2">
<label for="endTime" class="text-sm font-medium text-foreground">Uhrzeit</label>
<input type="time" id="endTime" class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors" bind:value={endTime} required />
<input
type="time"
id="endTime"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={endTime}
required
/>
</div>
{/if}
</div>
@ -190,7 +219,11 @@
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-border">
<button type="button" class="px-4 py-2 rounded-lg font-medium text-foreground bg-transparent hover:bg-muted transition-colors" onclick={onCancel}>
<button
type="button"
class="px-4 py-2 rounded-lg font-medium text-foreground bg-transparent hover:bg-muted transition-colors"
onclick={onCancel}
>
Abbrechen
</button>
<button type="submit" class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" disabled={submitting || !title.trim() || !calendarId}>
@ -198,4 +231,3 @@
</button>
</div>
</form>

View file

@ -59,12 +59,16 @@ export const eventsStore = {
if (!Array.isArray(currentEvents)) return [];
return currentEvents.filter((event) => {
const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
// For all-day events, check if day falls within event range
if (event.isAllDay) {
return isWithinInterval(date, { start: eventStart, end: eventEnd }) || isSameDay(date, eventStart);
return (
isWithinInterval(date, { start: eventStart, end: eventEnd }) ||
isSameDay(date, eventStart)
);
}
// For timed events, check if event starts on this day
@ -81,7 +85,8 @@ export const eventsStore = {
if (!Array.isArray(currentEvents)) return [];
return currentEvents.filter((event) => {
const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
// Check if event overlaps with the range

View file

@ -73,10 +73,7 @@
Neuer Termin
</button>
<MiniCalendar
selectedDate={viewStore.currentDate}
onDateSelect={handleDateSelect}
/>
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
<CalendarSidebar />
</aside>

View file

@ -83,9 +83,7 @@
{:else if groupedEvents.length === 0}
<div class="empty-state card">
<p>Keine Termine in den nächsten 30 Tagen</p>
<button class="btn btn-primary" onclick={() => goto('/event/new')}>
Termin erstellen
</button>
<button class="btn btn-primary" onclick={() => goto('/event/new')}> Termin erstellen </button>
</div>
{:else}
<div class="event-list">
@ -106,8 +104,16 @@
{#if event.isAllDay}
Ganztägig
{:else}
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
{format(
typeof event.startTime === 'string'
? parseISO(event.startTime)
: event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
{/if}
</div>
<div class="event-title">{event.title}</div>

View file

@ -71,15 +71,18 @@
<div class="calendars-page">
<header class="page-header">
<h1>Meine Kalender</h1>
<button class="btn btn-primary" onclick={() => (showNewForm = true)}>
Neuer Kalender
</button>
<button class="btn btn-primary" onclick={() => (showNewForm = true)}> Neuer Kalender </button>
</header>
{#if showNewForm}
<div class="card new-calendar-form">
<h2>Neuer Kalender</h2>
<form onsubmit={(e) => { e.preventDefault(); handleCreateCalendar(); }}>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreateCalendar();
}}
>
<div class="form-row">
<input
type="text"
@ -87,11 +90,7 @@
placeholder="Kalender Name"
bind:value={newCalendarName}
/>
<input
type="color"
class="color-input"
bind:value={newCalendarColor}
/>
<input type="color" class="color-input" bind:value={newCalendarColor} />
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" onclick={() => (showNewForm = false)}>
@ -119,26 +118,14 @@
}}
>
<div class="form-row">
<input
type="text"
name="name"
class="input"
value={calendar.name}
/>
<input
type="color"
name="color"
class="color-input"
value={calendar.color}
/>
<input type="text" name="name" class="input" value={calendar.name} />
<input type="color" name="color" class="color-input" value={calendar.color} />
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" onclick={() => (editingCalendar = null)}>
Abbrechen
</button>
<button type="submit" class="btn btn-primary">
Speichern
</button>
<button type="submit" class="btn btn-primary"> Speichern </button>
</div>
</form>
{:else}
@ -150,10 +137,7 @@
{/if}
</div>
<div class="calendar-actions">
<button
class="btn btn-ghost btn-sm"
onclick={() => (editingCalendar = calendar)}
>
<button class="btn btn-ghost btn-sm" onclick={() => (editingCalendar = calendar)}>
Bearbeiten
</button>
{#if !calendar.isDefault}

View file

@ -87,23 +87,14 @@
<h1 class="page-title">{isEditing ? 'Termin bearbeiten' : event.title}</h1>
{#if !isEditing}
<div class="actions">
<button class="btn btn-ghost" onclick={() => (isEditing = true)}>
Bearbeiten
</button>
<button class="btn btn-ghost text-destructive" onclick={handleDelete}>
Löschen
</button>
<button class="btn btn-ghost" onclick={() => (isEditing = true)}> Bearbeiten </button>
<button class="btn btn-ghost text-destructive" onclick={handleDelete}> Löschen </button>
</div>
{/if}
</div>
{#if isEditing}
<EventForm
mode="edit"
{event}
onSave={handleSave}
onCancel={handleCancel}
/>
<EventForm mode="edit" {event} onSave={handleSave} onCancel={handleCancel} />
{:else}
<div class="event-details">
<div class="detail-row">
@ -133,9 +124,7 @@
{/if}
<div class="detail-row">
<button class="btn btn-ghost" onclick={() => goto('/')}>
Zurück zum Kalender
</button>
<button class="btn btn-ghost" onclick={() => goto('/')}> Zurück zum Kalender </button>
</div>
</div>
{/if}

View file

@ -136,7 +136,9 @@
>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="5"></circle>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
<path
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
></path>
</svg>
Hell
</button>
@ -415,7 +417,10 @@
</div>
<div class="setting-item">
<button class="btn btn-ghost text-destructive" onclick={() => authStore.signOut().then(() => goto('/login'))}>
<button
class="btn btn-ghost text-destructive"
onclick={() => authStore.signOut().then(() => goto('/login'))}
>
Abmelden
</button>
</div>

View file

@ -232,11 +232,6 @@ export function getEventDurationMinutes(start: Date, end: Date): number {
/**
* Check if two time ranges overlap
*/
export function doTimeRangesOverlap(
start1: Date,
end1: Date,
start2: Date,
end2: Date
): boolean {
export function doTimeRangesOverlap(start1: Date, end1: Date, start2: Date, end2: Date): boolean {
return start1 < end2 && end1 > start2;
}

View file

@ -111,7 +111,11 @@ export function describeRecurrence(pattern: RecurrencePattern | null): string {
case 'WEEKLY':
if (pattern.byDay && pattern.byDay.length > 0) {
if (pattern.byDay.length === 5 && !pattern.byDay.includes('SA') && !pattern.byDay.includes('SU')) {
if (
pattern.byDay.length === 5 &&
!pattern.byDay.includes('SA') &&
!pattern.byDay.includes('SU')
) {
return interval === 1 ? 'Every weekday' : `Every ${interval} weeks on weekdays`;
}
const days = pattern.byDay.map(dayToLabel).join(', ');
@ -122,7 +126,9 @@ export function describeRecurrence(pattern: RecurrencePattern | null): string {
case 'MONTHLY':
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
const days = pattern.byMonthDay.join(', ');
return interval === 1 ? `Monthly on day ${days}` : `Every ${interval} months on day ${days}`;
return interval === 1
? `Monthly on day ${days}`
: `Every ${interval} months on day ${days}`;
}
return interval === 1 ? 'Monthly' : `Every ${interval} months`;
@ -205,7 +211,10 @@ export function generateOccurrences(
// Check if this date matches the pattern
if (matchesPattern(currentDate, pattern)) {
// Check if date is in range and not in exceptions
if (currentDate >= rangeStart && !exceptionsSet.has(currentDate.toISOString().split('T')[0])) {
if (
currentDate >= rangeStart &&
!exceptionsSet.has(currentDate.toISOString().split('T')[0])
) {
occurrences.push(new Date(currentDate));
}
}

View file

@ -5,7 +5,10 @@ export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.CONTACTS_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts',
url:
process.env.CONTACTS_DATABASE_URL ||
process.env.DATABASE_URL ||
'postgresql://manacore:devpassword@localhost:5432/contacts',
},
verbose: true,
strict: true,

View file

@ -10,11 +10,7 @@ export type ActivityType = 'created' | 'updated' | 'called' | 'emailed' | 'met'
export class ActivityService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByContactId(
contactId: string,
userId: string,
limit = 50
): Promise<ContactActivity[]> {
async findByContactId(contactId: string, userId: string, limit = 50): Promise<ContactActivity[]> {
return this.db
.select()
.from(contactActivities)

View file

@ -173,10 +173,7 @@ export class ContactController {
}
@Get(':id')
async findOne(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const contact = await this.contactService.findById(id, user.userId);
if (!contact) {
return { contact: null };
@ -212,10 +209,7 @@ export class ContactController {
}
@Delete(':id')
async delete(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.contactService.delete(id, user.userId);
return { success: true };
}

View file

@ -62,10 +62,7 @@ export class GroupController {
}
@Get(':id')
async findOne(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const group = await this.groupService.findById(id, user.userId);
const contactIds = group ? await this.groupService.getContactsInGroup(id) : [];
return { group, contactIds };
@ -91,10 +88,7 @@ export class GroupController {
}
@Delete(':id')
async delete(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.groupService.delete(id, user.userId);
return { success: true };
}

View file

@ -51,10 +51,7 @@ export class GroupService {
}
async addContactToGroup(contactId: string, groupId: string): Promise<void> {
await this.db
.insert(contactToGroups)
.values({ contactId, groupId })
.onConflictDoNothing();
await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing();
}
async removeContactFromGroup(contactId: string, groupId: string): Promise<void> {

View file

@ -77,19 +77,13 @@ export class NoteController {
}
@Delete(':id')
async delete(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.noteService.delete(id, user.userId);
return { success: true };
}
@Post(':id/pin')
async togglePin(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async togglePin(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const note = await this.noteService.togglePin(id, user.userId);
return { note };
}

View file

@ -67,10 +67,7 @@ export class TagController {
}
@Delete(':id')
async delete(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.tagService.delete(id, user.userId);
return { success: true };
}

View file

@ -30,9 +30,7 @@
</button>
{#if isOpen}
<div
class="absolute right-0 mt-2 w-40 rounded-md border border-border bg-card shadow-lg z-50"
>
<div class="absolute right-0 mt-2 w-40 rounded-md border border-border bg-card shadow-lg z-50">
{#each supportedLocales as lang}
<button
onclick={() => handleSelect(lang)}

View file

@ -36,7 +36,9 @@
class="flex items-center gap-3 rounded-lg bg-card px-4 py-3 shadow-lg border border-border animate-in slide-in-from-right duration-200"
>
<span
class="{getColorClass(toast.type)} flex h-6 w-6 items-center justify-center rounded-full text-white text-sm"
class="{getColorClass(
toast.type
)} flex h-6 w-6 items-center justify-center rounded-full text-white text-sm"
>
{getIcon(toast.type)}
</span>

View file

@ -52,10 +52,7 @@
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
<a
href="/contacts/new"
class="btn btn-primary flex items-center gap-2"
>
<a href="/contacts/new" class="btn btn-primary flex items-center gap-2">
<span>+</span>
<span>{$_('contacts.new')}</span>
</a>

View file

@ -93,7 +93,12 @@
<h1 class="title">Archiv</h1>
<div class="title-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
</div>
</header>
@ -102,7 +107,12 @@
{#if contacts.length > 0}
<div class="search-wrapper">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
@ -116,10 +126,15 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
<button onclick={() => error = null} class="dismiss-btn">&times;</button>
<button onclick={() => (error = null)} class="dismiss-btn">&times;</button>
</div>
{/if}
@ -131,14 +146,27 @@
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
</div>
<h2 class="empty-title">Archiv ist leer</h2>
<p class="empty-description">Archivierte Kontakte erscheinen hier. Du kannst sie später wiederherstellen oder endgültig löschen.</p>
<p class="empty-description">
Archivierte Kontakte erscheinen hier. Du kannst sie später wiederherstellen oder endgültig
löschen.
</p>
<a href="/" class="btn btn-primary">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<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>
Zu Kontakten
</a>
@ -147,7 +175,12 @@
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h2 class="empty-title">Keine Ergebnisse</h2>
@ -156,7 +189,12 @@
{:else}
<div class="info-banner">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Archivierte Kontakte können wiederhergestellt oder endgültig gelöscht werden.</span>
</div>
@ -201,7 +239,12 @@
title="Wiederherstellen"
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
@ -211,7 +254,12 @@
title="Endgültig löschen"
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
@ -219,7 +267,9 @@
{/each}
</div>
<p class="contacts-count">{contacts.length} archiviert{contacts.length !== 1 ? 'e Kontakte' : 'er Kontakt'}</p>
<p class="contacts-count">
{contacts.length} archiviert{contacts.length !== 1 ? 'e Kontakte' : 'er Kontakt'}
</p>
{/if}
</div>
@ -443,7 +493,11 @@
width: 3rem;
height: 3rem;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%);
background: linear-gradient(
135deg,
hsl(var(--color-primary)) 0%,
hsl(var(--color-primary) / 0.7) 100%
);
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;

View file

@ -153,14 +153,36 @@
<h1 class="title">{editing ? 'Bearbeiten' : 'Kontakt'}</h1>
{#if contact && !editing && !loading}
<div class="header-actions">
<button onclick={() => { editing = true; populateForm(); }} class="action-btn" aria-label="Bearbeiten">
<button
onclick={() => {
editing = true;
populateForm();
}}
class="action-btn"
aria-label="Bearbeiten"
>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button onclick={handleDelete} disabled={deleting} class="action-btn action-btn-danger" aria-label="Löschen">
<button
onclick={handleDelete}
disabled={deleting}
class="action-btn action-btn-danger"
aria-label="Löschen"
>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
@ -172,8 +194,20 @@
{#if loading}
<div class="loading-container">
<svg class="spinner-lg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
stroke-opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<p class="loading-text">Lade Kontakt...</p>
</div>
@ -181,7 +215,12 @@
<div class="error-container">
<div class="error-icon-wrapper">
<svg class="error-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="error-text">{error}</p>
@ -191,7 +230,12 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
@ -207,8 +251,18 @@
</div>
<button type="button" class="avatar-edit-btn" aria-label="Foto ändern">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
@ -218,13 +272,24 @@
{/if}
</div>
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="form">
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="form"
>
<!-- Name Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<h2 class="section-title">Name</h2>
@ -246,7 +311,12 @@
<div class="section-header">
<div class="section-icon">
<svg 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" />
<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>
</div>
<h2 class="section-title">Kontakt</h2>
@ -255,7 +325,12 @@
<label for="email" class="label">E-Mail</label>
<div class="input-with-icon">
<svg class="input-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
<input id="email" type="email" bind:value={email} class="input input-padded" />
</div>
@ -265,7 +340,12 @@
<label for="phone" class="label">Telefon</label>
<div class="input-with-icon">
<svg class="input-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
<input id="phone" type="tel" bind:value={phone} class="input input-padded" />
</div>
@ -274,7 +354,12 @@
<label for="mobile" class="label">Mobil</label>
<div class="input-with-icon">
<svg class="input-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<input id="mobile" type="tel" bind:value={mobile} class="input input-padded" />
</div>
@ -287,7 +372,12 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h2 class="section-title">Arbeit</h2>
@ -307,8 +397,18 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h2 class="section-title">Adresse</h2>
@ -338,7 +438,12 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<h2 class="section-title">Notizen</h2>
@ -348,19 +453,42 @@
<!-- Action Buttons -->
<div class="actions">
<button type="button" onclick={() => { editing = false; }} class="btn btn-secondary">
<button
type="button"
onclick={() => {
editing = false;
}}
class="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" disabled={saving} class="btn btn-primary">
{#if saving}
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
stroke-opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<span>Speichern...</span>
{:else}
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>Speichern</span>
{/if}
@ -375,14 +503,25 @@
<div class="avatar-circle avatar-large">
{initials()}
</div>
<button onclick={handleToggleFavorite} class="favorite-btn" aria-label={contact.isFavorite ? 'Von Favoriten entfernen' : 'Zu Favoriten hinzufügen'}>
<button
onclick={handleToggleFavorite}
class="favorite-btn"
aria-label={contact.isFavorite ? 'Von Favoriten entfernen' : 'Zu Favoriten hinzufügen'}
>
{#if contact.isFavorite}
<svg class="favorite-icon favorite-active" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<svg class="favorite-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{/if}
</button>
@ -401,7 +540,12 @@
<a href="tel:{contact.phone}" class="quick-action">
<div class="quick-action-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
</div>
<span>Anrufen</span>
@ -411,7 +555,12 @@
<a href="mailto:{contact.email}" class="quick-action">
<div class="quick-action-icon">
<svg 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" />
<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>
</div>
<span>E-Mail</span>
@ -421,7 +570,12 @@
<a href="sms:{contact.mobile}" class="quick-action">
<div class="quick-action-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<span>Nachricht</span>
@ -437,7 +591,12 @@
<div class="section-header">
<div class="section-icon">
<svg 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" />
<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>
</div>
<h3 class="section-title">Kontakt</h3>
@ -446,7 +605,12 @@
{#if contact.email}
<a href="mailto:{contact.email}" class="detail-item detail-link">
<svg class="detail-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
<div class="detail-content">
<span class="detail-label">E-Mail</span>
@ -457,7 +621,12 @@
{#if contact.phone}
<a href="tel:{contact.phone}" class="detail-item detail-link">
<svg class="detail-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
<div class="detail-content">
<span class="detail-label">Telefon</span>
@ -468,7 +637,12 @@
{#if contact.mobile}
<a href="tel:{contact.mobile}" class="detail-item detail-link">
<svg class="detail-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<div class="detail-content">
<span class="detail-label">Mobil</span>
@ -486,7 +660,12 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h3 class="section-title">Arbeit</h3>
@ -495,7 +674,12 @@
{#if contact.company}
<div class="detail-item">
<svg class="detail-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<div class="detail-content">
<span class="detail-label">Firma</span>
@ -506,7 +690,12 @@
{#if contact.jobTitle}
<div class="detail-item">
<svg class="detail-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<div class="detail-content">
<span class="detail-label">Position</span>
@ -524,8 +713,18 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h3 class="section-title">Adresse</h3>
@ -533,7 +732,9 @@
<div class="address-card">
{#if contact.street}<div class="address-line">{contact.street}</div>{/if}
{#if contact.postalCode || contact.city}
<div class="address-line">{[contact.postalCode, contact.city].filter(Boolean).join(' ')}</div>
<div class="address-line">
{[contact.postalCode, contact.city].filter(Boolean).join(' ')}
</div>
{/if}
{#if contact.country}<div class="address-line">{contact.country}</div>{/if}
</div>
@ -546,7 +747,12 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<h3 class="section-title">Notizen</h3>
@ -719,7 +925,11 @@
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%);
background: linear-gradient(
135deg,
hsl(var(--color-primary)) 0%,
hsl(var(--color-primary) / 0.7) 100%
);
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;

View file

@ -89,8 +89,18 @@
</div>
<button type="button" class="avatar-edit-btn" aria-label="Foto hinzufügen">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
@ -103,19 +113,35 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="form">
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<!-- Name Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<h2 class="section-title">Name</h2>
@ -149,7 +175,12 @@
<div class="section-header">
<div class="section-icon">
<svg 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" />
<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>
</div>
<h2 class="section-title">Kontakt</h2>
@ -158,7 +189,12 @@
<label for="email" class="label">E-Mail</label>
<div class="input-with-icon">
<svg class="input-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
<input
id="email"
@ -174,7 +210,12 @@
<label for="phone" class="label">Telefon</label>
<div class="input-with-icon">
<svg class="input-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
<input
id="phone"
@ -189,7 +230,12 @@
<label for="mobile" class="label">Mobil</label>
<div class="input-with-icon">
<svg class="input-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<input
id="mobile"
@ -208,7 +254,12 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h2 class="section-title">Arbeit</h2>
@ -240,8 +291,18 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h2 class="section-title">Adresse</h2>
@ -269,13 +330,7 @@
</div>
<div class="form-field col-span-2">
<label for="city" class="label">Stadt</label>
<input
id="city"
type="text"
bind:value={city}
class="input"
placeholder="Berlin"
/>
<input id="city" type="text" bind:value={city} class="input" placeholder="Berlin" />
</div>
</div>
<div class="form-field">
@ -295,7 +350,12 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<h2 class="section-title">Notizen</h2>
@ -310,19 +370,34 @@
<!-- Action Buttons -->
<div class="actions">
<a href="/" class="btn btn-secondary">
Abbrechen
</a>
<a href="/" class="btn btn-secondary"> Abbrechen </a>
<button type="submit" disabled={loading} class="btn btn-primary">
{#if loading}
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
stroke-opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<span>Speichern...</span>
{:else}
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>Kontakt speichern</span>
{/if}
@ -396,7 +471,11 @@
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%);
background: linear-gradient(
135deg,
hsl(var(--color-primary)) 0%,
hsl(var(--color-primary) / 0.7) 100%
);
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;

View file

@ -82,7 +82,9 @@
<h1 class="title">Favoriten</h1>
<div class="title-icon">
<svg class="icon" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</div>
</header>
@ -90,7 +92,12 @@
<!-- Search -->
<div class="search-wrapper">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
@ -103,10 +110,15 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
<button onclick={() => error = null} class="dismiss-btn">&times;</button>
<button onclick={() => (error = null)} class="dismiss-btn">&times;</button>
</div>
{/if}
@ -118,14 +130,26 @@
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</div>
<h2 class="empty-title">Keine Favoriten</h2>
<p class="empty-description">Markiere Kontakte als Favoriten, um sie hier schnell zu finden.</p>
<p class="empty-description">
Markiere Kontakte als Favoriten, um sie hier schnell zu finden.
</p>
<a href="/" class="btn btn-primary">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<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>
Zu Kontakten
</a>
@ -134,7 +158,12 @@
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h2 class="empty-title">Keine Ergebnisse</h2>
@ -179,7 +208,9 @@
aria-label="Aus Favoriten entfernen"
>
<svg class="heart-icon" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</button>
</div>
@ -399,7 +430,11 @@
width: 3rem;
height: 3rem;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%);
background: linear-gradient(
135deg,
hsl(var(--color-primary)) 0%,
hsl(var(--color-primary) / 0.7) 100%
);
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;

View file

@ -13,9 +13,7 @@
if (!searchQuery.trim()) return groups;
const query = searchQuery.toLowerCase();
return groups.filter(
(g) =>
g.name.toLowerCase().includes(query) ||
g.description?.toLowerCase().includes(query)
(g) => g.name.toLowerCase().includes(query) || g.description?.toLowerCase().includes(query)
);
});
@ -77,7 +75,12 @@
<!-- Search -->
<div class="search-wrapper">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
@ -90,7 +93,12 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
@ -104,14 +112,24 @@
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h2 class="empty-title">Keine Gruppen</h2>
<p class="empty-description">Erstelle deine erste Gruppe um Kontakte zu organisieren.</p>
<a href="/groups/new" class="btn btn-primary">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Neue Gruppe
</a>
@ -120,7 +138,12 @@
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h2 class="empty-title">Keine Ergebnisse</h2>
@ -150,11 +173,21 @@
aria-label="Gruppe löschen"
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<svg class="chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>

View file

@ -22,8 +22,18 @@
let color = $state('#6366f1');
const presetColors = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', '#14b8a6',
'#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#ec4899',
'#ef4444',
'#f97316',
'#f59e0b',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#ec4899',
];
const groupContacts = $derived(() => {
@ -162,11 +172,16 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="title">{isEditing ? 'Gruppe bearbeiten' : (group?.name || 'Gruppe')}</h1>
<h1 class="title">{isEditing ? 'Gruppe bearbeiten' : group?.name || 'Gruppe'}</h1>
{#if !loading && group && !isEditing}
<button onclick={() => isEditing = true} class="edit-button" aria-label="Bearbeiten">
<button onclick={() => (isEditing = true)} class="edit-button" aria-label="Bearbeiten">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{:else}
@ -182,7 +197,12 @@
<div class="error-state">
<div class="error-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 class="error-title">Fehler</h2>
@ -193,10 +213,15 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
<button onclick={() => error = null} class="dismiss-btn">&times;</button>
<button onclick={() => (error = null)} class="dismiss-btn">&times;</button>
</div>
{/if}
@ -205,18 +230,34 @@
<div class="preview-section">
<div class="preview-color" style="background-color: {color}">
<svg class="preview-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<p class="preview-name">{name || 'Gruppenname'}</p>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="form">
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="form"
>
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<h2 class="section-title">Details</h2>
@ -227,7 +268,8 @@
</div>
<div class="form-field">
<label for="description" class="label">Beschreibung</label>
<textarea id="description" bind:value={description} rows="3" class="input textarea"></textarea>
<textarea id="description" bind:value={description} rows="3" class="input textarea"
></textarea>
</div>
</section>
@ -235,7 +277,12 @@
<div class="section-header">
<div class="section-icon" style="background-color: {color}20; color: {color}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
</div>
<h2 class="section-title">Farbe</h2>
@ -247,11 +294,16 @@
class="color-option"
class:selected={color === presetColor}
style="background-color: {presetColor}"
onclick={() => color = presetColor}
onclick={() => (color = presetColor)}
>
{#if color === presetColor}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
@ -264,8 +316,22 @@
<button type="submit" disabled={saving} class="btn btn-primary">
{#if saving}
<svg class="spinner-sm" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" fill="none" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" fill="none" />
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
stroke-opacity="0.25"
fill="none"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
fill="none"
/>
</svg>
Speichern...
{:else}
@ -278,7 +344,12 @@
<!-- Delete Button -->
<button onclick={handleDelete} class="delete-group-btn">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Gruppe löschen
</button>
@ -287,7 +358,12 @@
<div class="preview-section">
<div class="preview-color" style="background-color: {group.color || '#6366f1'}">
<svg class="preview-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<p class="preview-name">{group.name}</p>
@ -301,13 +377,23 @@
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<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>
<h2 class="section-title">Kontakte ({groupContacts().length})</h2>
<button onclick={() => showAddContacts = true} class="add-contact-btn">
<button onclick={() => (showAddContacts = true)} class="add-contact-btn">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Hinzufügen
</button>
@ -332,9 +418,18 @@
<span class="contact-email">{contact.email}</span>
{/if}
</div>
<button onclick={() => handleRemoveContact(contact.id)} class="remove-btn" aria-label="Entfernen">
<button
onclick={() => handleRemoveContact(contact.id)}
class="remove-btn"
aria-label="Entfernen"
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@ -348,15 +443,20 @@
<!-- Add Contacts Modal -->
{#if showAddContacts}
<div class="modal-backdrop" onclick={() => showAddContacts = false} role="presentation">
<div class="modal-backdrop" onclick={() => (showAddContacts = false)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<div class="modal-header">
<h2 class="modal-title">Kontakte hinzufügen</h2>
<button onclick={() => showAddContacts = false} class="modal-close">&times;</button>
<button onclick={() => (showAddContacts = false)} class="modal-close">&times;</button>
</div>
<div class="modal-search">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
@ -368,7 +468,9 @@
<div class="modal-content">
{#if availableContacts().length === 0}
<p class="no-results">
{searchQuery ? 'Keine Kontakte gefunden' : 'Alle Kontakte sind bereits in dieser Gruppe'}
{searchQuery
? 'Keine Kontakte gefunden'
: 'Alle Kontakte sind bereits in dieser Gruppe'}
</p>
{:else}
{#each availableContacts() as contact (contact.id)}
@ -387,7 +489,12 @@
{/if}
</div>
<svg class="add-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
{/each}
@ -417,7 +524,8 @@
margin-bottom: 0.5rem;
}
.back-button, .edit-button {
.back-button,
.edit-button {
display: flex;
align-items: center;
justify-content: center;
@ -475,7 +583,9 @@
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.error-state {
@ -724,7 +834,8 @@
gap: 0.5rem;
}
.contact-item, .add-contact-item {
.contact-item,
.add-contact-item {
display: flex;
align-items: center;
gap: 0.75rem;
@ -753,7 +864,11 @@
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: linear-gradient(135deg, hsl(var(--color-primary)) 0%, hsl(var(--color-primary) / 0.7) 100%);
background: linear-gradient(
135deg,
hsl(var(--color-primary)) 0%,
hsl(var(--color-primary) / 0.7) 100%
);
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;
@ -816,7 +931,8 @@
color: hsl(var(--color-primary));
}
.no-contacts, .no-results {
.no-contacts,
.no-results {
text-align: center;
color: hsl(var(--color-muted-foreground));
padding: 1rem;

View file

@ -69,7 +69,12 @@
<div class="preview-section">
<div class="preview-color" style="background-color: {color}">
<svg class="preview-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<p class="preview-name">{name || 'Neue Gruppe'}</p>
@ -81,19 +86,35 @@
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="form">
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<!-- Name Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<h2 class="section-title">Gruppenname</h2>
@ -126,7 +147,12 @@
<div class="section-header">
<div class="section-icon" style="background-color: {color}20; color: {color}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
</div>
<h2 class="section-title">Farbe</h2>
@ -138,12 +164,17 @@
class="color-option"
class:selected={color === presetColor}
style="background-color: {presetColor}"
onclick={() => color = presetColor}
onclick={() => (color = presetColor)}
aria-label="Farbe {presetColor}"
>
{#if color === presetColor}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
@ -152,12 +183,7 @@
<div class="custom-color">
<label for="customColor" class="label">Oder eigene Farbe wählen:</label>
<div class="color-input-wrapper">
<input
id="customColor"
type="color"
bind:value={color}
class="color-input"
/>
<input id="customColor" type="color" bind:value={color} class="color-input" />
<input
type="text"
bind:value={color}
@ -171,19 +197,34 @@
<!-- Action Buttons -->
<div class="actions">
<a href="/groups" class="btn btn-secondary">
Abbrechen
</a>
<a href="/groups" class="btn btn-secondary"> Abbrechen </a>
<button type="submit" disabled={loading} class="btn btn-primary">
{#if loading}
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
stroke-opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<span>Erstellen...</span>
{:else}
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>Gruppe erstellen</span>
{/if}

View file

@ -85,7 +85,10 @@ export function useImageGeneration() {
setSteps(selectedModel.defaultSteps ?? 4);
setGuidanceScale(selectedModel.defaultGuidanceScale ?? 3.5);
const maxDimension = Math.min(selectedModel.maxWidth ?? 1024, selectedModel.maxHeight ?? 1024);
const maxDimension = Math.min(
selectedModel.maxWidth ?? 1024,
selectedModel.maxHeight ?? 1024
);
const minDimension = Math.max(selectedModel.minWidth ?? 256, selectedModel.minHeight ?? 256);
let newWidth = selectedAspectRatio.width;

View file

@ -12,7 +12,8 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<span>Presi</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Erstelle beeindruckende Präsentationen in Minuten. Mit KI-Unterstützung, schönen Themes und einfacher Bedienung.
Erstelle beeindruckende Präsentationen in Minuten. Mit KI-Unterstützung, schönen Themes
und einfacher Bedienung.
</p>
</div>
@ -21,17 +22,26 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<h4 class="text-text-muted text-xs uppercase tracking-wider mb-4">Produkt</h4>
<ul class="space-y-2">
<li>
<a href="#features" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="#features"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Features
</a>
</li>
<li>
<a href="https://presi.manacore.app" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="https://presi.manacore.app"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Web App
</a>
</li>
<li>
<a href="#download" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="#download"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Mobile App
</a>
</li>
@ -43,17 +53,26 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<h4 class="text-text-muted text-xs uppercase tracking-wider mb-4">Rechtliches</h4>
<ul class="space-y-2">
<li>
<a href="/privacy" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="/privacy"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Datenschutz
</a>
</li>
<li>
<a href="/terms" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="/terms"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
AGB
</a>
</li>
<li>
<a href="/imprint" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="/imprint"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Impressum
</a>
</li>
@ -63,11 +82,11 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<!-- Copyright -->
<div class="pt-8 border-t border-border text-center">
<p class="text-text-muted text-sm">
© 2025 Presi. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">© 2025 Presi. Alle Rechte vorbehalten.</p>
<p class="text-text-muted text-xs mt-1">
Ein Produkt von <a href="https://manacore.ai" class="hover:text-primary transition-colors">ManaCore</a>
Ein Produkt von <a href="https://manacore.ai" class="hover:text-primary transition-colors"
>ManaCore</a
>
</p>
</div>
</Container>

View file

@ -3,7 +3,9 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
import Button from '@manacore/shared-landing-ui/atoms/Button.astro';
---
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
<nav
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
>
<Container>
<div class="flex items-center justify-between h-16">
<!-- Logo -->
@ -17,7 +19,10 @@ import Button from '@manacore/shared-landing-ui/atoms/Button.astro';
<a href="#features" class="text-text-secondary hover:text-text-primary transition-colors">
Features
</a>
<a href="#how-it-works" class="text-text-secondary hover:text-text-primary transition-colors">
<a
href="#how-it-works"
class="text-text-secondary hover:text-text-primary transition-colors"
>
So funktioniert's
</a>
<a href="#faq" class="text-text-secondary hover:text-text-primary transition-colors">
@ -27,9 +32,7 @@ import Button from '@manacore/shared-landing-ui/atoms/Button.astro';
<!-- CTA -->
<div class="flex items-center gap-4">
<Button href="https://presi.manacore.app" variant="primary" size="sm">
App öffnen
</Button>
<Button href="https://presi.manacore.app" variant="primary" size="sm"> App öffnen </Button>
</div>
</div>
</Container>

View file

@ -72,23 +72,28 @@ const steps = [
const faqs = [
{
question: 'Ist Presi kostenlos?',
answer: 'Ja, Presi ist kostenlos nutzbar. Du kannst unbegrenzt Präsentationen erstellen, teilen und präsentieren.',
answer:
'Ja, Presi ist kostenlos nutzbar. Du kannst unbegrenzt Präsentationen erstellen, teilen und präsentieren.',
},
{
question: 'Kann ich Präsentationen offline bearbeiten?',
answer: 'Mit der mobilen App kannst du deine Präsentationen auch offline bearbeiten. Änderungen werden synchronisiert, sobald du wieder online bist.',
answer:
'Mit der mobilen App kannst du deine Präsentationen auch offline bearbeiten. Änderungen werden synchronisiert, sobald du wieder online bist.',
},
{
question: 'Wie teile ich eine Präsentation?',
answer: 'Klicke auf "Teilen" und erstelle einen Link. Jeder mit dem Link kann die Präsentation ansehen - ohne Account oder Download.',
answer:
'Klicke auf "Teilen" und erstelle einen Link. Jeder mit dem Link kann die Präsentation ansehen - ohne Account oder Download.',
},
{
question: 'Welche Slide-Typen gibt es?',
answer: 'Presi unterstützt Titel-Slides, Content-Slides mit Text und Bullet Points, Bild-Slides und Split-Views mit Text und Bild nebeneinander.',
answer:
'Presi unterstützt Titel-Slides, Content-Slides mit Text und Bullet Points, Bild-Slides und Split-Views mit Text und Bild nebeneinander.',
},
{
question: 'Kann ich eigene Themes erstellen?',
answer: 'Aktuell bieten wir vorgefertigte Themes. Custom Themes sind für zukünftige Versionen geplant.',
answer:
'Aktuell bieten wir vorgefertigte Themes. Custom Themes sind für zukünftige Versionen geplant.',
},
];
---
@ -122,31 +127,31 @@ const faqs = [
<section id="how-it-works" class="py-20 bg-background-card">
<Container>
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-text-primary mb-4">
So einfach geht's
</h2>
<h2 class="text-3xl md:text-4xl font-bold text-text-primary mb-4">So einfach geht's</h2>
<p class="text-text-secondary text-lg max-w-2xl mx-auto">
In vier Schritten zur perfekten Präsentation
</p>
</div>
<div class="grid md:grid-cols-4 gap-6">
{steps.map((step, index) => (
<div class="relative">
<div class="bg-background-page rounded-2xl p-6 border border-border hover:border-primary/30 transition-all duration-300 h-full">
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<span class="text-primary font-bold text-xl">{step.number}</span>
{
steps.map((step, index) => (
<div class="relative">
<div class="bg-background-page rounded-2xl p-6 border border-border hover:border-primary/30 transition-all duration-300 h-full">
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<span class="text-primary font-bold text-xl">{step.number}</span>
</div>
<h3 class="text-text-primary font-semibold text-lg mb-2">{step.title}</h3>
<p class="text-text-secondary text-sm">{step.description}</p>
</div>
<h3 class="text-text-primary font-semibold text-lg mb-2">{step.title}</h3>
<p class="text-text-secondary text-sm">{step.description}</p>
{index < steps.length - 1 && (
<div class="hidden md:block absolute top-1/2 -right-3 transform -translate-y-1/2 text-text-muted">
</div>
)}
</div>
{index < steps.length - 1 && (
<div class="hidden md:block absolute top-1/2 -right-3 transform -translate-y-1/2 text-text-muted">
</div>
)}
</div>
))}
))
}
</div>
</Container>
</section>
@ -170,8 +175,8 @@ const faqs = [
</h2>
<p class="text-text-secondary text-lg mb-8 leading-relaxed">
Der Präsentationsmodus bietet alles was du brauchst: Vollbild-Ansicht,
Tastaturnavigation mit Pfeiltasten, Timer für perfektes Timing und
Speaker Notes für deine Notizen.
Tastaturnavigation mit Pfeiltasten, Timer für perfektes Timing und Speaker Notes für
deine Notizen.
</p>
<div class="bg-background-page rounded-2xl p-8 border border-border">
<div class="flex flex-wrap justify-center gap-4 text-sm text-text-secondary">

View file

@ -5,7 +5,10 @@ export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.STORAGE_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/storage',
url:
process.env.STORAGE_DATABASE_URL ||
process.env.DATABASE_URL ||
'postgresql://manacore:devpassword@localhost:5432/storage',
},
verbose: true,
strict: true,

View file

@ -1,4 +1,13 @@
import { pgTable, uuid, varchar, text, timestamp, bigint, boolean, integer } from 'drizzle-orm/pg-core';
import {
pgTable,
uuid,
varchar,
text,
timestamp,
bigint,
boolean,
integer,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { folders } from './folders.schema';

View file

@ -29,7 +29,10 @@ export class FileController {
constructor(private readonly fileService: FileService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query('parentFolderId') parentFolderId?: string) {
async findAll(
@CurrentUser() user: CurrentUserData,
@Query('parentFolderId') parentFolderId?: string
) {
return this.fileService.findAll(user.userId, parentFolderId);
}
@ -101,12 +104,20 @@ export class FileController {
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: UpdateFileDto) {
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateFileDto
) {
return this.fileService.update(user.userId, id, dto);
}
@Patch(':id/move')
async move(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: MoveFileDto) {
async move(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: MoveFileDto
) {
return this.fileService.move(user.userId, id, dto);
}

View file

@ -19,7 +19,11 @@ export class FileService {
.select()
.from(files)
.where(
and(eq(files.userId, userId), eq(files.parentFolderId, parentFolderId), eq(files.isDeleted, false))
and(
eq(files.userId, userId),
eq(files.parentFolderId, parentFolderId),
eq(files.isDeleted, false)
)
);
}
@ -27,7 +31,9 @@ export class FileService {
return this.db
.select()
.from(files)
.where(and(eq(files.userId, userId), isNull(files.parentFolderId), eq(files.isDeleted, false)));
.where(
and(eq(files.userId, userId), isNull(files.parentFolderId), eq(files.isDeleted, false))
);
}
async findOne(userId: string, id: string): Promise<File> {
@ -43,11 +49,7 @@ export class FileService {
return result[0];
}
async upload(
userId: string,
file: Express.Multer.File,
dto: CreateFileDto
): Promise<File> {
async upload(userId: string, file: Express.Multer.File, dto: CreateFileDto): Promise<File> {
if (!file) {
throw new BadRequestException('No file provided');
}

View file

@ -1,4 +1,14 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, type CurrentUserData } from '@manacore/shared-nestjs-auth';
import { FolderService } from './folder.service';
import { CreateFolderDto } from './dto/create-folder.dto';
@ -10,7 +20,10 @@ export class FolderController {
constructor(private readonly folderService: FolderService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query('parentFolderId') parentFolderId?: string) {
async findAll(
@CurrentUser() user: CurrentUserData,
@Query('parentFolderId') parentFolderId?: string
) {
return this.folderService.findAll(user.userId, parentFolderId);
}
@ -25,12 +38,20 @@ export class FolderController {
}
@Patch(':id')
async update(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: UpdateFolderDto) {
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateFolderDto
) {
return this.folderService.update(user.userId, id, dto);
}
@Patch(':id/move')
async move(@CurrentUser() user: CurrentUserData, @Param('id') id: string, @Body() dto: MoveFolderDto) {
async move(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: MoveFolderDto
) {
return this.folderService.move(user.userId, id, dto);
}

View file

@ -28,7 +28,13 @@ export class FolderService {
return this.db
.select()
.from(folders)
.where(and(eq(folders.userId, userId), isNull(folders.parentFolderId), eq(folders.isDeleted, false)));
.where(
and(
eq(folders.userId, userId),
isNull(folders.parentFolderId),
eq(folders.isDeleted, false)
)
);
}
async findOne(userId: string, id: string): Promise<Folder> {

View file

@ -47,7 +47,9 @@ export class SearchService {
const favoriteFolders = await this.db
.select()
.from(folders)
.where(and(eq(folders.userId, userId), eq(folders.isDeleted, false), eq(folders.isFavorite, true)));
.where(
and(eq(folders.userId, userId), eq(folders.isDeleted, false), eq(folders.isFavorite, true))
);
return { files: favoriteFiles, folders: favoriteFolders };
}

View file

@ -27,7 +27,9 @@ export class StorageService {
subfolder?: string
) {
if (!validateFileSize(buffer.length, this.maxFileSize / (1024 * 1024))) {
throw new Error(`File size exceeds maximum allowed size of ${this.maxFileSize / (1024 * 1024)}MB`);
throw new Error(
`File size exceeds maximum allowed size of ${this.maxFileSize / (1024 * 1024)}MB`
);
}
const storageKey = generateUserFileKey(userId, originalName, subfolder);

View file

@ -13,7 +13,10 @@ export class TagController {
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: { name: string; color?: string }) {
async create(
@CurrentUser() user: CurrentUserData,
@Body() dto: { name: string; color?: string }
) {
return this.tagService.create(user.userId, dto.name, dto.color);
}

View file

@ -46,7 +46,9 @@ export class TagService {
}
async removeTagFromFile(fileId: string, tagId: string): Promise<void> {
await this.db.delete(fileTags).where(and(eq(fileTags.fileId, fileId), eq(fileTags.tagId, tagId)));
await this.db
.delete(fileTags)
.where(and(eq(fileTags.fileId, fileId), eq(fileTags.tagId, tagId)));
}
async getFileTags(fileId: string): Promise<Tag[]> {

View file

@ -99,6 +99,8 @@ export class TrashService {
// Delete from database
await this.db.delete(files).where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
await this.db.delete(folders).where(and(eq(folders.userId, userId), eq(folders.isDeleted, true)));
await this.db
.delete(folders)
.where(and(eq(folders.userId, userId), eq(folders.isDeleted, true)));
}
}

View file

@ -22,10 +22,7 @@ async function getHeaders(): Promise<HeadersInit> {
return headers;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
try {
const headers = await getHeaders();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
@ -170,11 +167,9 @@ export const filesApi = {
body: JSON.stringify({ parentFolderId }),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/files/${id}`, { method: 'DELETE' }),
delete: (id: string) => request<{ success: boolean }>(`/files/${id}`, { method: 'DELETE' }),
toggleFavorite: (id: string) =>
request<StorageFile>(`/files/${id}/favorite`, { method: 'POST' }),
toggleFavorite: (id: string) => request<StorageFile>(`/files/${id}/favorite`, { method: 'POST' }),
};
// Folders API
@ -205,8 +200,7 @@ export const foldersApi = {
body: JSON.stringify({ parentFolderId }),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/folders/${id}`, { method: 'DELETE' }),
delete: (id: string) => request<{ success: boolean }>(`/folders/${id}`, { method: 'DELETE' }),
toggleFavorite: (id: string) =>
request<StorageFolder>(`/folders/${id}/favorite`, { method: 'POST' }),
@ -232,8 +226,7 @@ export const sharesApi = {
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/shares/${id}`, { method: 'DELETE' }),
delete: (id: string) => request<{ success: boolean }>(`/shares/${id}`, { method: 'DELETE' }),
};
// Tags API
@ -252,8 +245,7 @@ export const tagsApi = {
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/tags/${id}`, { method: 'DELETE' }),
delete: (id: string) => request<{ success: boolean }>(`/tags/${id}`, { method: 'DELETE' }),
};
// Trash API
@ -274,8 +266,9 @@ export const trashApi = {
// Search API
export const searchApi = {
search: (query: string) =>
request<{ files: StorageFile[]; folders: StorageFolder[] }>(`/search?q=${encodeURIComponent(query)}`),
request<{ files: StorageFile[]; folders: StorageFolder[] }>(
`/search?q=${encodeURIComponent(query)}`
),
favorites: () =>
request<{ files: StorageFile[]; folders: StorageFolder[] }>('/favorites'),
favorites: () => request<{ files: StorageFile[]; folders: StorageFolder[] }>('/favorites'),
};

View file

@ -63,7 +63,12 @@
</button>
</div>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="form-group">
<label for="folder-name">Ordnername</label>
<input

View file

@ -39,7 +39,12 @@
Funktionen du dir wünschst.
</p>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div class="form-group">
<label>Art des Feedbacks</label>
<div class="type-selector">

View file

@ -24,7 +24,10 @@
class:active={theme.variant === variant}
onclick={() => theme.setVariant(variant)}
>
<div class="theme-preview" style="background: linear-gradient(135deg, {def.colors.primary}, {def.colors.accent})">
<div
class="theme-preview"
style="background: linear-gradient(135deg, {def.colors.primary}, {def.colors.accent})"
>
{#if theme.variant === variant}
<div class="check-badge">
<Check size={16} />

View file

@ -132,10 +132,7 @@
<RotateCcw size={16} />
Wiederherstellen
</button>
<button
class="delete-btn"
onclick={() => handlePermanentDelete(folder.id, 'folder')}
>
<button class="delete-btn" onclick={() => handlePermanentDelete(folder.id, 'folder')}>
Endgültig löschen
</button>
</div>

View file

@ -0,0 +1 @@
export * from './types';

View file

@ -0,0 +1,72 @@
export interface StorageFile {
id: string;
userId: string;
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId: string | null;
currentVersion: number;
isFavorite: boolean;
isDeleted: boolean;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface StorageFolder {
id: string;
userId: string;
name: string;
description: string | null;
parentFolderId: string | null;
path: string;
depth: number;
isFavorite: boolean;
isDeleted: boolean;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface FileVersion {
id: string;
fileId: string;
versionNumber: number;
storagePath: string;
storageKey: string;
size: number;
comment: string | null;
createdBy: string;
createdAt: Date;
}
export interface Share {
id: string;
userId: string;
fileId: string | null;
folderId: string | null;
shareType: 'file' | 'folder';
shareToken: string;
accessLevel: 'view' | 'edit' | 'download';
password: string | null;
maxDownloads: number | null;
downloadCount: number;
expiresAt: Date | null;
createdAt: Date;
}
export interface Tag {
id: string;
userId: string;
name: string;
color: string;
createdAt: Date;
}
export interface FileTag {
fileId: string;
tagId: string;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -12,7 +12,8 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<span>Zitare</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Deine tägliche Quelle für Inspiration und Weisheit. Entdecke über 1000 Zitate von den größten Denkern der Geschichte.
Deine tägliche Quelle für Inspiration und Weisheit. Entdecke über 1000 Zitate von den
größten Denkern der Geschichte.
</p>
</div>
@ -21,17 +22,26 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<h4 class="text-text-muted text-xs uppercase tracking-wider mb-4">Produkt</h4>
<ul class="space-y-2">
<li>
<a href="#features" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="#features"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Features
</a>
</li>
<li>
<a href="https://zitare.manacore.app" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="https://zitare.manacore.app"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Web App
</a>
</li>
<li>
<a href="#download" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="#download"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Mobile App
</a>
</li>
@ -43,17 +53,26 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<h4 class="text-text-muted text-xs uppercase tracking-wider mb-4">Rechtliches</h4>
<ul class="space-y-2">
<li>
<a href="/privacy" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="/privacy"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Datenschutz
</a>
</li>
<li>
<a href="/terms" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="/terms"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
AGB
</a>
</li>
<li>
<a href="/imprint" class="text-text-secondary hover:text-text-primary text-sm transition-colors">
<a
href="/imprint"
class="text-text-secondary hover:text-text-primary text-sm transition-colors"
>
Impressum
</a>
</li>
@ -63,11 +82,11 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
<!-- Copyright -->
<div class="pt-8 border-t border-border text-center">
<p class="text-text-muted text-sm">
© 2025 Zitare. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">© 2025 Zitare. Alle Rechte vorbehalten.</p>
<p class="text-text-muted text-xs mt-1">
Ein Produkt von <a href="https://manacore.ai" class="hover:text-primary transition-colors">ManaCore</a>
Ein Produkt von <a href="https://manacore.ai" class="hover:text-primary transition-colors"
>ManaCore</a
>
</p>
</div>
</Container>

View file

@ -3,7 +3,9 @@ import Container from '@manacore/shared-landing-ui/atoms/Container.astro';
import Button from '@manacore/shared-landing-ui/atoms/Button.astro';
---
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
<nav
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
>
<Container>
<div class="flex items-center justify-between h-16">
<!-- Logo -->
@ -27,9 +29,7 @@ import Button from '@manacore/shared-landing-ui/atoms/Button.astro';
<!-- CTA -->
<div class="flex items-center gap-4">
<Button href="https://zitare.manacore.app" variant="primary" size="sm">
App öffnen
</Button>
<Button href="https://zitare.manacore.app" variant="primary" size="sm"> App öffnen </Button>
</div>
</div>
</Container>

View file

@ -6,10 +6,7 @@ interface Props {
description?: string;
}
const {
title,
description = 'Zitare - Inspirierende Zitate von großen Denkern',
} = Astro.props;
const { title, description = 'Zitare - Inspirierende Zitate von großen Denkern' } = Astro.props;
---
<!doctype html>

View file

@ -64,23 +64,28 @@ const sampleQuotes = [
const faqs = [
{
question: 'Ist Zitare kostenlos?',
answer: 'Ja, Zitare ist kostenlos nutzbar. Du hast Zugriff auf alle Zitate und Features ohne Abo oder versteckte Kosten.',
answer:
'Ja, Zitare ist kostenlos nutzbar. Du hast Zugriff auf alle Zitate und Features ohne Abo oder versteckte Kosten.',
},
{
question: 'Welche Autoren sind in der Sammlung?',
answer: 'Unsere Sammlung umfasst über 1000 Zitate von Philosophen wie Sokrates und Nietzsche, Wissenschaftlern wie Einstein und Curie, sowie modernen Denkern und Führungspersönlichkeiten.',
answer:
'Unsere Sammlung umfasst über 1000 Zitate von Philosophen wie Sokrates und Nietzsche, Wissenschaftlern wie Einstein und Curie, sowie modernen Denkern und Führungspersönlichkeiten.',
},
{
question: 'Kann ich Zitate offline lesen?',
answer: 'Ja, mit der mobilen App werden deine Lieblingszitate lokal gespeichert. So hast du auch ohne Internetverbindung Zugriff auf Inspiration.',
answer:
'Ja, mit der mobilen App werden deine Lieblingszitate lokal gespeichert. So hast du auch ohne Internetverbindung Zugriff auf Inspiration.',
},
{
question: 'Wie kann ich Zitate teilen?',
answer: 'Jedes Zitat kann direkt aus der App geteilt werden - per WhatsApp, Instagram, E-Mail oder als Bild für Social Media.',
answer:
'Jedes Zitat kann direkt aus der App geteilt werden - per WhatsApp, Instagram, E-Mail oder als Bild für Social Media.',
},
{
question: 'Werden neue Zitate hinzugefügt?',
answer: 'Ja, wir erweitern unsere Sammlung regelmäßig mit neuen, sorgfältig ausgewählten Zitaten aus verschiedenen Epochen und Kulturen.',
answer:
'Ja, wir erweitern unsere Sammlung regelmäßig mit neuen, sorgfältig ausgewählten Zitaten aus verschiedenen Epochen und Kulturen.',
},
];
---
@ -123,15 +128,19 @@ const faqs = [
</div>
<div class="grid md:grid-cols-3 gap-6">
{sampleQuotes.map((quote) => (
<div class="bg-background-page rounded-2xl p-8 border border-border hover:border-primary/30 transition-all duration-300 group">
<div class="text-4xl text-primary/30 mb-4 group-hover:text-primary/50 transition-colors">"</div>
<p class="quote-text text-text-primary text-lg mb-6 leading-relaxed">
{quote.text}
</p>
<p class="text-text-muted text-sm">— {quote.author}</p>
</div>
))}
{
sampleQuotes.map((quote) => (
<div class="bg-background-page rounded-2xl p-8 border border-border hover:border-primary/30 transition-all duration-300 group">
<div class="text-4xl text-primary/30 mb-4 group-hover:text-primary/50 transition-colors">
"
</div>
<p class="quote-text text-text-primary text-lg mb-6 leading-relaxed">
{quote.text}
</p>
<p class="text-text-muted text-sm">— {quote.author}</p>
</div>
))
}
</div>
</Container>
</section>
@ -150,13 +159,11 @@ const faqs = [
<section id="about" class="py-20 bg-background-card">
<Container size="md">
<div class="text-center">
<h2 class="text-3xl md:text-4xl font-bold text-text-primary mb-6">
Über Zitare
</h2>
<h2 class="text-3xl md:text-4xl font-bold text-text-primary mb-6">Über Zitare</h2>
<p class="text-text-secondary text-lg mb-6 leading-relaxed">
Zitare ist deine tägliche Quelle für Inspiration und Weisheit. Wir haben über 1000 Zitate
von den einflussreichsten Denkern, Philosophen, Wissenschaftlern und Führungspersönlichkeiten
der Geschichte sorgfältig zusammengestellt.
Zitare ist deine tägliche Quelle für Inspiration und Weisheit. Wir haben über 1000
Zitate von den einflussreichsten Denkern, Philosophen, Wissenschaftlern und
Führungspersönlichkeiten der Geschichte sorgfältig zusammengestellt.
</p>
<p class="text-text-secondary text-lg leading-relaxed">
Ob du Motivation suchst, nach Weisheit strebst oder einfach einen Moment der Reflexion

View file

@ -6,7 +6,7 @@ services:
# ============================================
mana-core-auth:
image: ${DOCKER_REGISTRY:-wuesteon}/mana-core-auth:${AUTH_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/mana-core-auth:${AUTH_VERSION:-latest}
container_name: mana-core-auth-prod
restart: always
environment:
@ -44,7 +44,7 @@ services:
memory: 256M
maerchenzauber-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/maerchenzauber-backend:${MAERCHENZAUBER_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/maerchenzauber-backend:${MAERCHENZAUBER_VERSION:-latest}
container_name: maerchenzauber-backend-prod
restart: always
depends_on:
@ -84,7 +84,7 @@ services:
memory: 512M
chat-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/chat-backend:${CHAT_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/chat-backend:${CHAT_VERSION:-latest}
container_name: chat-backend-prod
restart: always
depends_on:
@ -123,7 +123,7 @@ services:
memory: 512M
manadeck-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/manadeck-backend:${MANADECK_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/manadeck-backend:${MANADECK_VERSION:-latest}
container_name: manadeck-backend-prod
restart: always
depends_on:
@ -159,7 +159,7 @@ services:
memory: 256M
nutriphi-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/nutriphi-backend:${NUTRIPHI_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/nutriphi-backend:${NUTRIPHI_VERSION:-latest}
container_name: nutriphi-backend-prod
restart: always
depends_on:
@ -195,7 +195,7 @@ services:
memory: 256M
news-api:
image: ${DOCKER_REGISTRY:-wuesteon}/news-api:${NEWS_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/news-api:${NEWS_VERSION:-latest}
container_name: news-api-prod
restart: always
depends_on:

View file

@ -48,7 +48,7 @@ services:
# ============================================
mana-core-auth:
image: ${DOCKER_REGISTRY:-wuesteon}/mana-core-auth:${AUTH_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/mana-core-auth:${AUTH_VERSION:-latest}
container_name: mana-core-auth-staging
restart: unless-stopped
depends_on:
@ -83,7 +83,7 @@ services:
max-file: "3"
maerchenzauber-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/maerchenzauber-backend:${MAERCHENZAUBER_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/maerchenzauber-backend:${MAERCHENZAUBER_VERSION:-latest}
container_name: maerchenzauber-backend-staging
restart: unless-stopped
depends_on:
@ -115,7 +115,7 @@ services:
max-file: "3"
chat-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/chat-backend:${CHAT_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/chat-backend:${CHAT_VERSION:-latest}
container_name: chat-backend-staging
restart: unless-stopped
depends_on:
@ -146,7 +146,7 @@ services:
max-file: "3"
manadeck-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/manadeck-backend:${MANADECK_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/manadeck-backend:${MANADECK_VERSION:-latest}
container_name: manadeck-backend-staging
restart: unless-stopped
depends_on:
@ -174,7 +174,7 @@ services:
max-file: "3"
nutriphi-backend:
image: ${DOCKER_REGISTRY:-wuesteon}/nutriphi-backend:${NUTRIPHI_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/nutriphi-backend:${NUTRIPHI_VERSION:-latest}
container_name: nutriphi-backend-staging
restart: unless-stopped
depends_on:
@ -202,7 +202,7 @@ services:
max-file: "3"
news-api:
image: ${DOCKER_REGISTRY:-wuesteon}/news-api:${NEWS_VERSION:-latest}
image: ${DOCKER_REGISTRY:-ghcr.io/memo-2023}/news-api:${NEWS_VERSION:-latest}
container_name: news-api-staging
restart: unless-stopped
depends_on:

View file

@ -10,7 +10,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.65.0",
"@azure/openai": "^2.0.0",
"openai": "^4.76.0",
"@google/genai": "^1.14.0",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",

View file

@ -8,7 +8,7 @@ import { ConfigService } from '@nestjs/config';
import { GenerateGameDto, GenerateGameResponseDto } from './dto/generate-game.dto';
import { GoogleGenAI } from '@google/genai';
import Anthropic from '@anthropic-ai/sdk';
import { AzureOpenAI } from '@azure/openai';
import { AzureOpenAI } from 'openai';
type AIProvider = 'google' | 'anthropic' | 'azure';

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build": "rm -rf dist && astro build",
"preview": "astro preview",
"astro": "astro"
},

3
lint-staged.config.js Normal file
View file

@ -0,0 +1,3 @@
export default {
'*.{ts,tsx,js,jsx,json,md,svelte,astro}': ['prettier --config .prettierrc.json --write'],
};

View file

@ -112,10 +112,13 @@
"deploy:landing:all": "pnpm deploy:landing:chat && pnpm deploy:landing:picture && pnpm deploy:landing:manacore && pnpm deploy:landing:manadeck && pnpm deploy:landing:zitare && pnpm deploy:landing:presi",
"cf:login": "npx wrangler login",
"cf:projects:list": "npx wrangler pages project list",
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main"
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main",
"prepare": "husky"
},
"devDependencies": {
"concurrently": "^9.2.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-svelte": "^3.4.0",

View file

@ -11,7 +11,9 @@
"default": "./dist/index.js"
}
},
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit",

288
pnpm-lock.yaml generated
View file

@ -11,6 +11,12 @@ importers:
concurrently:
specifier: ^9.2.0
version: 9.2.1
husky:
specifier: ^9.1.7
version: 9.1.7
lint-staged:
specifier: ^16.2.7
version: 16.2.7
prettier:
specifier: ^3.3.3
version: 3.6.2
@ -2966,10 +2972,7 @@ importers:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.65.0
version: 0.65.0(zod@4.1.13)
'@azure/openai':
specifier: ^2.0.0
version: 2.0.0
version: 0.65.0(zod@3.25.76)
'@google/genai':
specifier: ^1.14.0
version: 1.30.0
@ -2991,6 +2994,9 @@ importers:
class-validator:
specifier: ^0.14.1
version: 0.14.3
openai:
specifier: ^4.76.0
version: 4.104.0(ws@8.18.3)(zod@3.25.76)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@ -4120,38 +4126,6 @@ packages:
resolution: {integrity: sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww==}
engines: {node: '>=18.0.0'}
'@azure-rest/core-client@2.5.1':
resolution: {integrity: sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==}
engines: {node: '>=20.0.0'}
'@azure/abort-controller@2.1.2':
resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==}
engines: {node: '>=18.0.0'}
'@azure/core-auth@1.10.1':
resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==}
engines: {node: '>=20.0.0'}
'@azure/core-rest-pipeline@1.22.2':
resolution: {integrity: sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==}
engines: {node: '>=20.0.0'}
'@azure/core-tracing@1.3.1':
resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==}
engines: {node: '>=20.0.0'}
'@azure/core-util@1.13.1':
resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==}
engines: {node: '>=20.0.0'}
'@azure/logger@1.3.0':
resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==}
engines: {node: '>=20.0.0'}
'@azure/openai@2.0.0':
resolution: {integrity: sha512-zSNhwarYbqg3P048uKMjEjbge41OnAgmiiE1elCHVsuCCXRyz2BXnHMJkW6WR6ZKQy5NHswJNUNSWsuqancqFA==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.10.4':
resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==}
@ -9260,10 +9234,6 @@ packages:
resolution: {integrity: sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typespec/ts-http-runtime@0.3.2':
resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
engines: {node: '>=20.0.0'}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@ -9720,6 +9690,10 @@ packages:
resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==}
engines: {node: '>=14.16'}
ansi-escapes@7.2.0:
resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==}
engines: {node: '>=18'}
ansi-regex@4.1.1:
resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
engines: {node: '>=6'}
@ -10429,6 +10403,10 @@ packages:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
@ -10437,6 +10415,10 @@ packages:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
engines: {node: 10.* || >= 12.*}
cli-truncate@5.1.1:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
cli-width@3.0.0:
resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
engines: {node: '>= 10'}
@ -10519,6 +10501,9 @@ packages:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@ -10534,6 +10519,10 @@ packages:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
commander@14.0.2:
resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@ -11331,6 +11320,10 @@ packages:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
environment@1.1.0:
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
engines: {node: '>=18'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -13088,6 +13081,11 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
hasBin: true
hyphenate-style-name@1.1.0:
resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==}
@ -13305,6 +13303,10 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-fullwidth-code-point@5.1.0:
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
engines: {node: '>=18'}
is-generator-fn@2.1.0:
resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==}
engines: {node: '>=6'}
@ -14127,6 +14129,15 @@ packages:
linkify-it@2.2.0:
resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==}
lint-staged@16.2.7:
resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==}
engines: {node: '>=20.17'}
hasBin: true
listr2@9.0.5:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
load-esm@1.0.3:
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
engines: {node: '>=13.2.0'}
@ -14214,6 +14225,10 @@ packages:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
log-update@6.1.0:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
@ -14780,6 +14795,10 @@ packages:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@ -14909,6 +14928,10 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nano-spawn@2.0.0:
resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==}
engines: {node: '>=20.17'}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -15149,6 +15172,10 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
@ -15390,6 +15417,11 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pidtree@0.6.0:
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
engines: {node: '>=0.10'}
hasBin: true
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@ -16410,6 +16442,10 @@ packages:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
@ -16429,6 +16465,9 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rimraf@2.6.3:
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@ -16717,6 +16756,10 @@ packages:
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
engines: {node: '>=14.16'}
slice-ansi@7.1.2:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
slugify@1.6.6:
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
engines: {node: '>=8.0.0'}
@ -16850,6 +16893,10 @@ packages:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
string-length@4.0.2:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'}
@ -16870,6 +16917,10 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string-width@8.1.0:
resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==}
engines: {node: '>=20'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@ -18673,6 +18724,12 @@ snapshots:
'@antfu/utils@8.1.1': {}
'@anthropic-ai/sdk@0.65.0(zod@3.25.76)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
'@anthropic-ai/sdk@0.65.0(zod@4.1.13)':
dependencies:
json-schema-to-ts: 3.1.1
@ -19340,67 +19397,6 @@ snapshots:
'@aws/lambda-invoke-store@0.2.1': {}
'@azure-rest/core-client@2.5.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-rest-pipeline': 1.22.2
'@azure/core-tracing': 1.3.1
'@typespec/ts-http-runtime': 0.3.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/abort-controller@2.1.2':
dependencies:
tslib: 2.8.1
'@azure/core-auth@1.10.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-util': 1.13.1
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-rest-pipeline@1.22.2':
dependencies:
'@azure/abort-controller': 2.1.2
'@azure/core-auth': 1.10.1
'@azure/core-tracing': 1.3.1
'@azure/core-util': 1.13.1
'@azure/logger': 1.3.0
'@typespec/ts-http-runtime': 0.3.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/core-tracing@1.3.1':
dependencies:
tslib: 2.8.1
'@azure/core-util@1.13.1':
dependencies:
'@azure/abort-controller': 2.1.2
'@typespec/ts-http-runtime': 0.3.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/logger@1.3.0':
dependencies:
'@typespec/ts-http-runtime': 0.3.2
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@azure/openai@2.0.0':
dependencies:
'@azure-rest/core-client': 2.5.1
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@babel/code-frame@7.10.4':
dependencies:
'@babel/highlight': 7.25.9
@ -26899,14 +26895,6 @@ snapshots:
'@typescript-eslint/types': 8.48.0
eslint-visitor-keys: 4.2.1
'@typespec/ts-http-runtime@0.3.2':
dependencies:
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
tslib: 2.8.1
transitivePeerDependencies:
- supports-color
'@ungap/structured-clone@1.3.0': {}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
@ -27439,6 +27427,10 @@ snapshots:
ansi-escapes@6.2.1: {}
ansi-escapes@7.2.0:
dependencies:
environment: 1.1.0
ansi-regex@4.1.1: {}
ansi-regex@5.0.1: {}
@ -28576,6 +28568,10 @@ snapshots:
dependencies:
restore-cursor: 3.1.0
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
cli-spinners@2.9.2: {}
cli-table3@0.6.5:
@ -28584,6 +28580,11 @@ snapshots:
optionalDependencies:
'@colors/colors': 1.5.0
cli-truncate@5.1.1:
dependencies:
slice-ansi: 7.1.2
string-width: 8.1.0
cli-width@3.0.0: {}
cli-width@4.1.0: {}
@ -28653,6 +28654,8 @@ snapshots:
color-convert: 3.1.3
color-string: 2.1.4
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@ -28663,6 +28666,8 @@ snapshots:
commander@12.1.0: {}
commander@14.0.2: {}
commander@2.20.3: {}
commander@4.1.1: {}
@ -29316,6 +29321,8 @@ snapshots:
env-paths@3.0.0: {}
environment@1.1.0: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@ -32703,6 +32710,7 @@ snapshots:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
https-proxy-agent@5.0.1:
dependencies:
@ -32724,6 +32732,8 @@ snapshots:
dependencies:
ms: 2.1.3
husky@9.1.7: {}
hyphenate-style-name@1.1.0: {}
i18next-browser-languagedetector@7.2.2:
@ -32971,6 +32981,10 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
is-fullwidth-code-point@5.1.0:
dependencies:
get-east-asian-width: 1.4.0
is-generator-fn@2.1.0: {}
is-generator-function@1.1.2:
@ -34551,6 +34565,25 @@ snapshots:
dependencies:
uc.micro: 1.0.6
lint-staged@16.2.7:
dependencies:
commander: 14.0.2
listr2: 9.0.5
micromatch: 4.0.8
nano-spawn: 2.0.0
pidtree: 0.6.0
string-argv: 0.3.2
yaml: 2.8.1
listr2@9.0.5:
dependencies:
cli-truncate: 5.1.1
colorette: 2.0.20
eventemitter3: 5.0.1
log-update: 6.1.0
rfdc: 1.4.1
wrap-ansi: 9.0.2
load-esm@1.0.3: {}
load-tsconfig@0.2.5: {}
@ -34622,6 +34655,14 @@ snapshots:
chalk: 4.1.2
is-unicode-supported: 0.1.0
log-update@6.1.0:
dependencies:
ansi-escapes: 7.2.0
cli-cursor: 5.0.0
slice-ansi: 7.1.2
strip-ansi: 7.1.2
wrap-ansi: 9.0.2
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
@ -35814,6 +35855,8 @@ snapshots:
mimic-fn@2.1.0: {}
mimic-function@5.0.1: {}
min-indent@1.0.1: {}
mini-svg-data-uri@1.4.4: {}
@ -35950,6 +35993,8 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nano-spawn@2.0.0: {}
nanoid@3.3.11: {}
nanoid@5.1.6: {}
@ -36225,6 +36270,10 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.4:
@ -36475,6 +36524,8 @@ snapshots:
picomatch@4.0.3: {}
pidtree@0.6.0: {}
pify@2.3.0: {}
pify@4.0.1: {}
@ -38085,6 +38136,11 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0
signal-exit: 4.1.0
restructure@3.0.2: {}
retext-latin@4.0.0:
@ -38114,6 +38170,8 @@ snapshots:
reusify@1.1.0: {}
rfdc@1.4.1: {}
rimraf@2.6.3:
dependencies:
glob: 7.2.3
@ -38533,6 +38591,11 @@ snapshots:
slash@5.1.0: {}
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
slugify@1.6.6: {}
smol-toml@1.5.2: {}
@ -38659,6 +38722,8 @@ snapshots:
strict-uri-encode@2.0.0: {}
string-argv@0.3.2: {}
string-length@4.0.2:
dependencies:
char-regex: 1.0.2
@ -38687,6 +38752,11 @@ snapshots:
get-east-asian-width: 1.4.0
strip-ansi: 7.1.2
string-width@8.1.0:
dependencies:
get-east-asian-width: 1.4.0
strip-ansi: 7.1.2
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8