mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
Merge branch 'main' of https://github.com/Memo-2023/manacore-monorepo
This commit is contained in:
commit
ba746fce04
92 changed files with 6668 additions and 823 deletions
127
.claude/GUIDELINES.md
Normal file
127
.claude/GUIDELINES.md
Normal 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
|
||||
```
|
||||
597
.claude/guidelines/authentication.md
Normal file
597
.claude/guidelines/authentication.md
Normal 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
|
||||
312
.claude/guidelines/code-style.md
Normal file
312
.claude/guidelines/code-style.md
Normal 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
|
||||
```
|
||||
493
.claude/guidelines/database.md
Normal file
493
.claude/guidelines/database.md
Normal 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));
|
||||
```
|
||||
605
.claude/guidelines/error-handling.md
Normal file
605
.claude/guidelines/error-handling.md
Normal 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
|
||||
789
.claude/guidelines/expo-mobile.md
Normal file
789
.claude/guidelines/expo-mobile.md
Normal 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
|
||||
659
.claude/guidelines/nestjs-backend.md
Normal file
659
.claude/guidelines/nestjs-backend.md
Normal 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
|
||||
```
|
||||
764
.claude/guidelines/sveltekit-web.md
Normal file
764
.claude/guidelines/sveltekit-web.md
Normal 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>
|
||||
```
|
||||
577
.claude/guidelines/testing.md
Normal file
577
.claude/guidelines/testing.md
Normal 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
2
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pnpm exec lint-staged
|
||||
pnpm run type-check
|
||||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -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 |
|
||||
|
|
|
|||
37
COMMANDS.md
37
COMMANDS.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
© {currentYear} Kalender. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -73,10 +73,7 @@
|
|||
Neuer Termin
|
||||
</button>
|
||||
|
||||
<MiniCalendar
|
||||
selectedDate={viewStore.currentDate}
|
||||
onDateSelect={handleDateSelect}
|
||||
/>
|
||||
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
|
||||
|
||||
<CalendarSidebar />
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">×</button>
|
||||
<button onclick={() => (error = null)} class="dismiss-btn">×</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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">×</button>
|
||||
<button onclick={() => (error = null)} class="dismiss-btn">×</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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">×</button>
|
||||
<button onclick={() => (error = null)} class="dismiss-btn">×</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">×</button>
|
||||
<button onclick={() => (showAddContacts = false)} class="modal-close">×</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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
1
apps/storage/packages/shared/src/index.ts
Normal file
1
apps/storage/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './types';
|
||||
72
apps/storage/packages/shared/src/types/index.ts
Normal file
72
apps/storage/packages/shared/src/types/index.ts
Normal 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;
|
||||
}
|
||||
16
apps/storage/packages/shared/tsconfig.json
Normal file
16
apps/storage/packages/shared/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
3
lint-staged.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
'*.{ts,tsx,js,jsx,json,md,svelte,astro}': ['prettier --config .prettierrc.json --write'],
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit",
|
||||
|
|
|
|||
288
pnpm-lock.yaml
generated
288
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue