💄 style: apply prettier formatting across codebase

Run prettier --write to fix formatting inconsistencies in 80 files
across calendar, contacts, picture, presi, storage, zitare apps
and shared packages/documentation.
This commit is contained in:
Wuesteon 2025-12-03 02:02:09 +01:00
parent 6c9e8972a7
commit ea3582d487
79 changed files with 3122 additions and 2387 deletions

View file

@ -8,15 +8,16 @@ All projects use the root `.prettierrc.json`:
```json
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"]
"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
@ -27,39 +28,39 @@ All projects use the root `.prettierrc.json`:
### 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` |
| 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` |
| 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` |
| 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
@ -69,12 +70,12 @@ All projects use strict TypeScript:
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
```
@ -83,24 +84,24 @@ All projects use strict TypeScript:
```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;
email: string;
name: string;
password: string;
}
// BAD - Avoid `any`
function process(data: any) { } // Never do this
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
}
if (isUser(data)) {
// Now TypeScript knows it's a User
}
}
```
@ -108,13 +109,13 @@ function process(data: unknown) {
```typescript
// Order: external → internal → relative
import { Injectable } from '@nestjs/common'; // 1. External
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
import { UserService } from '../services/user.service'; // 3. Relative
// Use named exports (not default)
export { UserService }; // GOOD
export default UserService; // AVOID
export { UserService }; // GOOD
export default UserService; // AVOID
// Use type-only imports for types
import type { User } from './user.types';
@ -152,18 +153,19 @@ import type { User } from './user.types';
// 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();
.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) { }
for (const user of users) {
}
// BAD - Outdated comment
// Returns the user's email <-- but function now returns full user object
function getUser() { }
function getUser() {}
```
### JSDoc for Public APIs
@ -234,15 +236,19 @@ components/
```typescript
// BAD
if (user.role === 'admin') { }
if (credits < 10) { }
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) { }
if (user.role === ROLES.ADMIN) {
}
if (credits < MIN_CREDITS_FOR_OPERATION) {
}
```
### 2. Nested Callbacks
@ -250,11 +256,11 @@ if (credits < MIN_CREDITS_FOR_OPERATION) { }
```typescript
// BAD
getUser(id, (user) => {
getCredits(user.id, (credits) => {
updateBalance(credits, (result) => {
// ...
});
});
getCredits(user.id, (credits) => {
updateBalance(credits, (result) => {
// ...
});
});
});
// GOOD
@ -268,12 +274,12 @@ const result = await updateBalance(credits);
```typescript
// BAD
function processUser(user: User): void {
user.name = user.name.trim(); // Mutates input
user.name = user.name.trim(); // Mutates input
}
// GOOD
function processUser(user: User): User {
return { ...user, name: user.name.trim() }; // Returns new object
return { ...user, name: user.name.trim() }; // Returns new object
}
```
@ -285,10 +291,10 @@ createUser(email, password, true, false);
// GOOD - Use options object
createUser({
email,
password,
sendWelcomeEmail: true,
requireEmailVerification: false,
email,
password,
sendWelcomeEmail: true,
requireEmailVerification: false,
});
```

View file

@ -7,6 +7,7 @@ All projects use **Drizzle ORM** with **PostgreSQL**. This document covers schem
## ORM: Drizzle
### Why Drizzle?
- Full TypeScript type inference
- SQL-like syntax (no magic)
- Lightweight and fast
@ -24,30 +25,30 @@ 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;
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;
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;
}
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;
@ -65,22 +66,22 @@ 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],
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();
}
async onModuleDestroy() {
await closeConnection();
}
}
```
@ -104,44 +105,57 @@ src/db/
```typescript
// src/db/schema/files.schema.ts
import { pgTable, uuid, varchar, text, boolean, timestamp, bigint, integer } from 'drizzle-orm/pg-core';
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(),
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' }),
// 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(),
// 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'),
// Optional fields
description: text('description'),
// Boolean flags with defaults
isFavorite: boolean('is_favorite').default(false).notNull(),
isPublic: boolean('is_public').default(false).notNull(),
// 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 }),
// 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),
}));
// 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;
@ -153,22 +167,22 @@ export type NewFile = typeof files.$inferInsert;
```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),
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),
parent: one(folders, {
fields: [folders.parentFolderId],
references: [folders.id],
relationName: 'parentChild',
}),
children: many(folders, { relationName: 'parentChild' }),
files: many(files),
}));
```
@ -176,30 +190,30 @@ export const foldersRelations = relations(folders, ({ one, many }) => ({
### Tables
| Rule | Example |
|------|---------|
| Use snake_case | `user_sessions`, `file_versions` |
| Use plural nouns | `users`, `files`, `tags` |
| Junction tables: `{entity1}_{entity2}` | `file_tags`, `user_roles` |
| 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` |
| 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)
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
@ -309,27 +323,27 @@ type TransactionType = typeof transactionTypeEnum.enumValues[number];
```typescript
async function getPaginated(
userId: string,
page: number = 1,
limit: number = 20
userId: string,
page: number = 1,
limit: number = 20
): Promise<Result<{ items: File[]; total: number }>> {
const offset = (page - 1) * limit;
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))),
]);
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 });
return ok({ items, total: countResult[0].count });
}
```
@ -342,14 +356,14 @@ async function getPaginated(
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,
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
```
@ -378,14 +392,14 @@ 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);
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');
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete');
await connection.end();
await connection.end();
}
runMigrations().catch(console.error);
@ -397,32 +411,27 @@ runMigrations().catch(console.error);
```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);
.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();
// All operations in same transaction
const [file] = await tx.insert(files).values(newFile).returning();
await tx
.insert(fileVersions)
.values({ fileId: file.id, versionNumber: 1 });
await tx.insert(fileVersions).values({ fileId: file.id, versionNumber: 1 });
return file;
return file;
});
```
@ -430,12 +439,12 @@ const result = await db.transaction(async (tx) => {
```typescript
await db
.insert(userSettings)
.values({ userId, theme: 'dark' })
.onConflictDoUpdate({
target: userSettings.userId,
set: { theme: 'dark', updatedAt: new Date() },
});
.insert(userSettings)
.values({ userId, theme: 'dark' })
.onConflictDoUpdate({
target: userSettings.userId,
set: { theme: 'dark', updatedAt: new Date() },
});
```
## Anti-Patterns
@ -446,15 +455,15 @@ await db
// 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!
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));
.select()
.from(files)
.leftJoin(fileTags, eq(files.id, fileTags.fileId))
.leftJoin(tags, eq(fileTags.tagId, tags.id));
```
### 2. Missing Indexes

View file

@ -5,6 +5,7 @@
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
@ -16,25 +17,48 @@ The error handling system is implemented in `packages/shared-errors/`. Import fr
```typescript
import {
// Result type and helpers
Result, AsyncResult, ok, err, isOk, isErr,
unwrap, unwrapOr, map, andThen, match,
tryCatch, tryCatchAsync, combine,
// 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 codes
ErrorCode,
ERROR_CODE_TO_HTTP_STATUS,
// Error classes
AppError, ValidationError, NotFoundError,
AuthError, CreditError, ServiceError,
RateLimitError, NetworkError, DatabaseError,
// Error classes
AppError,
ValidationError,
NotFoundError,
AuthError,
CreditError,
ServiceError,
RateLimitError,
NetworkError,
DatabaseError,
// Type guards
isAppError, isValidationError, isNotFoundError,
hasErrorCode, isRetryable, getHttpStatus,
// Type guards
isAppError,
isValidationError,
isNotFoundError,
hasErrorCode,
isRetryable,
getHttpStatus,
// Utilities
wrap, toAppError,
// Utilities
wrap,
toAppError,
} from '@manacore/shared-errors';
```
@ -45,8 +69,8 @@ import {
```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 };
| { 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>>;
@ -63,9 +87,9 @@ const error = err(new NotFoundError('User', userId));
```typescript
// Base error class
class AppError extends Error {
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
code: ErrorCode;
context?: ErrorContext;
cause?: Error;
}
// Specialized error classes
@ -85,53 +109,53 @@ 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',
// 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',
// 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',
// 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',
// 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',
// Payment/Credit (402)
INSUFFICIENT_CREDITS = 'INSUFFICIENT_CREDITS',
PAYMENT_REQUIRED = 'PAYMENT_REQUIRED',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Conflict (409)
CONFLICT = 'CONFLICT',
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
// Rate Limiting (429)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
// 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',
// 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',
// Network Errors
NETWORK_ERROR = 'NETWORK_ERROR',
TIMEOUT = 'TIMEOUT',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Database Errors
DATABASE_ERROR = 'DATABASE_ERROR',
CONSTRAINT_VIOLATION = 'CONSTRAINT_VIOLATION',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
// Unknown
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
```
@ -154,8 +178,8 @@ import { isRetryable } from '@manacore/shared-errors';
// Check if an error is worth retrying
if (isRetryable(error)) {
await delay(1000);
return retry(operation);
await delay(1000);
return retry(operation);
}
```
@ -167,82 +191,83 @@ if (isRetryable(error)) {
// src/files/file.service.ts
import { Injectable, Inject } from '@nestjs/common';
import {
AsyncResult, ok, err, isOk,
NotFoundError, DatabaseError, ValidationError
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) {}
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)
));
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));
}
if (!file) {
return err(new NotFoundError('File', id));
}
return ok(file);
} catch (error) {
return err(DatabaseError.query('Failed to fetch file', error));
}
}
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'));
}
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,
};
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));
}
}
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
}
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));
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));
}
}
return ok(undefined);
} catch (error) {
return err(DatabaseError.query('Failed to delete file', error));
}
}
}
```
@ -258,46 +283,37 @@ import { FileService } from './file.service';
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileController {
constructor(private readonly fileService: FileService) {}
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);
@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
}
if (!isOk(result)) {
throw result.error; // AppError extends Error, caught by exception filter
}
return { file: result.value };
}
return { file: result.value };
}
@Post()
async createFile(
@Body() dto: CreateFileDto,
@CurrentUser() user: CurrentUserData
) {
const result = await this.fileService.create(user.userId, dto);
@Post()
async createFile(@Body() dto: CreateFileDto, @CurrentUser() user: CurrentUserData) {
const result = await this.fileService.create(user.userId, dto);
if (!isOk(result)) {
throw result.error;
}
if (!isOk(result)) {
throw result.error;
}
return { file: result.value };
}
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));
@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 };
}
return { success: true };
}
}
```
@ -314,6 +330,7 @@ app.useGlobalFilters(new AppExceptionFilter());
```
The filter automatically:
- Maps `ErrorCode` to HTTP status codes
- Returns consistent JSON error format
- Logs server errors (5xx)
@ -328,26 +345,26 @@ import { AppError, isAppError, getHttpStatus } from '@manacore/shared-errors';
@Catch(AppError)
export class AppExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('AppException');
private readonly logger = new Logger('AppException');
catch(exception: AppError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = getHttpStatus(exception);
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);
}
// 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,
},
});
}
response.status(status).json({
ok: false,
error: {
code: exception.code,
message: exception.message,
},
});
}
}
```
@ -360,56 +377,54 @@ export class AppExceptionFilter implements ExceptionFilter {
import { Result, err, ErrorCode, AppError } from '@manacore/shared-errors';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
ok: boolean;
data?: T;
error?: AppError;
}
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<Result<T>> {
try {
const token = await getAuthToken();
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 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();
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',
},
};
}
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');
}
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' }),
},
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' }),
},
};
```
@ -417,49 +432,49 @@ export const api = {
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { ErrorCode } from '@manacore/shared-errors';
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);
let files = $state<File[]>([]);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadFiles() {
loading = true;
error = null;
async function loadFiles() {
loading = true;
error = null;
const result = await api.files.list();
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;
}
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;
}
loading = false;
}
async function deleteFile(id: string) {
const result = await api.files.delete(id);
async function deleteFile(id: string) {
const result = await api.files.delete(id);
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
if (!result.ok) {
showToast({ type: 'error', message: result.error.message });
return;
}
files = files.filter(f => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
files = files.filter((f) => f.id !== id);
showToast({ type: 'success', message: 'File deleted' });
}
</script>
```
@ -472,37 +487,37 @@ 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 [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 loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
const result = await api.files.list();
const result = await api.files.list();
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
if (!result.ok) {
setError(result.error);
} else {
setFiles(result.data);
}
setLoading(false);
}, []);
setLoading(false);
}, []);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
const deleteFile = useCallback(async (id: string): Promise<boolean> => {
const result = await api.files.delete(id);
if (!result.ok) {
return false;
}
if (!result.ok) {
return false;
}
setFiles(prev => prev.filter(f => f.id !== id));
return true;
}, []);
setFiles((prev) => prev.filter((f) => f.id !== id));
return true;
}, []);
return { files, loading, error, loadFiles, deleteFile };
return { files, loading, error, loadFiles, deleteFile };
}
```
@ -512,36 +527,34 @@ export function useFiles() {
```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
}
// 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 }
);
}
// 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,
});
// 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;
}
if (!saveResult.ok) {
// Cleanup on failure
await storageService.delete(uploadResult.data.path);
return saveResult;
}
return saveResult;
return saveResult;
}
```
@ -552,24 +565,24 @@ import { Logger } from '@nestjs/common';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
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,
});
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');
}
}
// Return user-friendly error
return err(ErrorCode.DATABASE_ERROR, 'Failed to create file');
}
}
}
```

File diff suppressed because it is too large Load diff

View file

@ -50,43 +50,43 @@ 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');
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',
];
// 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,
});
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 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());
// Global exception filter
app.useGlobalFilters(new AppExceptionFilter());
// API prefix
app.setGlobalPrefix('api/v1');
// API prefix
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
}
bootstrap();
@ -104,16 +104,16 @@ import { FileModule } from './file/file.module';
import { FolderModule } from './folder/folder.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
FileModule,
FolderModule,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
FileModule,
FolderModule,
],
})
export class AppModule {}
```
@ -125,16 +125,16 @@ export class AppModule {}
```typescript
// src/file/file.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
UseGuards,
ParseUUIDPipe,
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';
@ -142,60 +142,48 @@ import { FileService } from './file.service';
import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Controller('files')
@UseGuards(JwtAuthGuard) // Apply to all routes in controller
@UseGuards(JwtAuthGuard) // Apply to all routes in controller
export class FileController {
constructor(private readonly fileService: FileService) {}
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()
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 };
}
@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 };
}
@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 };
}
@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 };
}
@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 };
}
}
```
@ -204,12 +192,12 @@ export class FileController {
```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 };
}
@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 };
}
}
```
@ -228,129 +216,120 @@ import { CreateFileDto, UpdateFileDto, QueryFilesDto } from './dto';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
private readonly logger = new Logger(FileService.name);
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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),
];
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));
}
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);
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');
}
}
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)
)
);
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`);
}
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');
}
}
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');
}
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,
};
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');
}
}
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;
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();
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');
}
}
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;
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));
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');
}
}
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');
}
}
}
```
@ -359,49 +338,49 @@ export class FileService {
```typescript
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
private readonly logger = new Logger(UploadService.name);
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private readonly storageService: StorageService,
private readonly fileService: FileService
) {}
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 }
);
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');
}
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,
});
// 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;
}
if (!createResult.ok) {
// Cleanup on failure
await this.storageService.delete(storageResult.data.key);
return createResult;
}
return createResult;
}
return createResult;
}
}
```
@ -414,34 +393,34 @@ export class UploadService {
import { IsString, IsOptional, IsNumber, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateFileDto {
@IsString()
@MaxLength(500)
name: string;
@IsString()
@MaxLength(500)
name: string;
@IsOptional()
@IsString()
@MaxLength(500)
originalName?: string;
@IsOptional()
@IsString()
@MaxLength(500)
originalName?: string;
@IsString()
@MaxLength(255)
mimeType: string;
@IsString()
@MaxLength(255)
mimeType: string;
@IsNumber()
@Min(0)
size: number;
@IsNumber()
@Min(0)
size: number;
@IsString()
@MaxLength(1000)
storagePath: string;
@IsString()
@MaxLength(1000)
storagePath: string;
@IsString()
@MaxLength(500)
storageKey: string;
@IsString()
@MaxLength(500)
storageKey: string;
@IsOptional()
@IsUUID()
folderId?: string;
@IsOptional()
@IsUUID()
folderId?: string;
}
```
@ -452,14 +431,14 @@ export class CreateFileDto {
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
export class UpdateFileDto {
@IsOptional()
@IsString()
@MaxLength(500)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
name?: string;
@IsOptional()
@IsUUID()
parentFolderId?: string | null;
@IsOptional()
@IsUUID()
parentFolderId?: string | null;
}
```
@ -471,22 +450,22 @@ import { IsOptional, IsUUID, IsNumber, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class QueryFilesDto {
@IsOptional()
@IsUUID()
folderId?: string;
@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(1)
@Max(100)
limit?: number = 50;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(0)
offset?: number = 0;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@Min(0)
offset?: number = 0;
}
```
@ -510,10 +489,10 @@ 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
imports: [StorageModule],
controllers: [FileController],
providers: [FileService, UploadService],
exports: [FileService], // Export for use in other modules
})
export class FileModule {}
```
@ -522,46 +501,40 @@ export class FileModule {}
```typescript
// src/common/filters/app-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpStatus,
Logger,
} from '@nestjs/common';
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);
private readonly logger = new Logger(AppExceptionFilter.name);
catch(exception: AppException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
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;
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,
});
}
// 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,
}),
},
});
}
response.status(status).json({
ok: false,
error: {
code: exception.error.code,
message: exception.error.message,
...(process.env.NODE_ENV === 'development' && {
details: exception.error.details,
}),
},
});
}
}
```
@ -575,24 +548,24 @@ 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 };
}
@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 };
}
}
```
@ -607,25 +580,25 @@ import { sql } from 'drizzle-orm';
@Controller('health')
export class HealthController {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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',
};
}
}
@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',
};
}
}
}
```

View file

@ -44,22 +44,22 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Reactive state
let count = $state(0);
let name = $state('');
let items = $state<string[]>([]);
// Object state
let user = $state<User | null>(null);
// Object state
let user = $state<User | null>(null);
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
// Functions that modify state
function increment() {
count++; // Direct mutation works
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
function addItem(item: string) {
items = [...items, item]; // Or reassignment
}
</script>
```
@ -67,25 +67,21 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
let count = $state(0);
let items = $state<Item[]>([]);
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);
// 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))
);
// 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`
);
// Derived with conditions
const displayText = $derived(
count === 0 ? 'No items' : count === 1 ? '1 item' : `${count} items`
);
</script>
```
@ -93,30 +89,30 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
import { browser } from '$app/environment';
import { browser } from '$app/environment';
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
let searchQuery = $state('');
let results = $state<SearchResult[]>([]);
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// Run effect when dependencies change
$effect(() => {
if (!browser) return;
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// This runs when searchQuery changes
const timer = setTimeout(async () => {
results = await search(searchQuery);
}, 300);
// Cleanup function
return () => clearTimeout(timer);
});
// Cleanup function
return () => clearTimeout(timer);
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
// Effect for initialization
$effect(() => {
if (browser) {
loadInitialData();
}
});
</script>
```
@ -124,34 +120,29 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
import type { File } from '$lib/types';
import type { File } from '$lib/types';
// Define props with types
interface Props {
file: File;
selected?: boolean;
onDelete?: (id: string) => void;
onSelect?: (file: File) => void;
}
// 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();
// Destructure with defaults
let { file, selected = false, onDelete, onSelect }: Props = $props();
function handleDelete() {
onDelete?.(file.id);
}
function handleDelete() {
onDelete?.(file.id);
}
</script>
<div class:selected onclick={() => onSelect?.(file)}>
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
<span>{file.name}</span>
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
</div>
```
@ -159,12 +150,12 @@ apps/{project}/apps/web/
```svelte
<script lang="ts">
interface Props {
value: string;
disabled?: boolean;
}
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<input bind:value {disabled} />
@ -190,68 +181,76 @@ 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 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;
if (!browser) return;
loading = true;
error = null;
loading = true;
error = null;
const result = await api.files.list(folderId);
const result = await api.files.list(folderId);
if (result.ok) {
files = result.data;
} else {
error = result.error;
}
if (result.ok) {
files = result.data;
} else {
error = result.error;
}
loading = false;
loading = false;
}
async function deleteFile(id: string): Promise<boolean> {
const result = await api.files.delete(id);
const result = await api.files.delete(id);
if (result.ok) {
files = files.filter(f => f.id !== id);
if (selectedId === id) selectedId = null;
return true;
}
if (result.ok) {
files = files.filter((f) => f.id !== id);
if (selectedId === id) selectedId = null;
return true;
}
error = result.error;
return false;
error = result.error;
return false;
}
function selectFile(id: string | null): void {
selectedId = id;
selectedId = id;
}
function reset(): void {
files = [];
loading = false;
error = null;
selectedId = null;
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; },
// 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,
// Actions
loadFiles,
deleteFile,
selectFile,
reset,
};
```
@ -259,32 +258,32 @@ export const fileStore = {
```svelte
<script lang="ts">
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
import { fileStore } from '$lib/stores/files.svelte';
import { onMount } from 'svelte';
onMount(() => {
fileStore.loadFiles();
});
onMount(() => {
fileStore.loadFiles();
});
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
async function handleDelete(id: string) {
const success = await fileStore.deleteFile(id);
if (success) {
showToast('File deleted');
}
}
</script>
{#if fileStore.loading}
<LoadingSpinner />
<LoadingSpinner />
{:else if fileStore.error}
<ErrorMessage message={fileStore.error.message} />
<ErrorMessage message={fileStore.error.message} />
{:else}
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => fileStore.selectFile(file.id)}
onDelete={handleDelete}
/>
<FileList
files={fileStore.files}
selectedId={fileStore.selectedFile?.id}
onSelect={(file) => fileStore.selectFile(file.id)}
onDelete={handleDelete}
/>
{/if}
```
@ -300,90 +299,85 @@ import { ErrorCode } from '@manacore/shared-errors';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiResponse<T> {
ok: boolean;
data?: T;
error?: AppError;
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' } };
}
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;
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,
},
});
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' } };
}
// 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();
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' },
};
}
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' },
};
}
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}` : ''}`),
files: {
list: (folderId?: string) =>
request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),
get: (id: string) =>
request<File>(`/api/v1/files/${id}`),
get: (id: string) => request<File>(`/api/v1/files/${id}`),
create: (data: CreateFileDto) =>
request<File>('/api/v1/files', {
method: 'POST',
body: JSON.stringify(data),
}),
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),
}),
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' }),
},
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),
}),
},
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),
}),
},
};
```
@ -412,32 +406,32 @@ src/routes/
```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';
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();
let { children } = $props();
// Check auth on mount
$effect(() => {
if (browser && !authStore.isAuthenticated) {
goto('/login');
}
});
// 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>
<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>
<div class="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
{/if}
```
@ -446,41 +440,41 @@ src/routes/
```svelte
<!-- src/routes/(app)/files/[id]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api/client';
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);
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);
});
// Load file when ID changes
$effect(() => {
const fileId = $page.params.id;
loadFile(fileId);
});
async function loadFile(id: string) {
loading = true;
error = null;
async function loadFile(id: string) {
loading = true;
error = null;
const result = await api.files.get(id);
const result = await api.files.get(id);
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
if (result.ok) {
file = result.data;
} else {
error = result.error.message;
}
loading = false;
}
loading = false;
}
</script>
{#if loading}
<LoadingSpinner />
<LoadingSpinner />
{:else if error}
<ErrorMessage message={error} />
<ErrorMessage message={error} />
{:else if file}
<FileViewer {file} />
<FileViewer {file} />
{/if}
```
@ -491,51 +485,51 @@ src/routes/
```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';
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;
}
interface Props {
file: File;
selected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
let { file, selected = false, onSelect, onDelete }: Props = $props();
let { file, selected = false, onSelect, onDelete }: Props = $props();
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
const formattedSize = $derived(formatBytes(file.size));
const formattedDate = $derived(formatDate(file.createdAt));
</script>
<div
class="p-4 rounded-lg border transition-colors cursor-pointer
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?.()}
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 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>
<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>
{#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>
```
@ -606,13 +600,13 @@ src/routes/
import sharedConfig from '@manacore/shared-tailwind';
export default {
presets: [sharedConfig],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
// Project-specific overrides
},
},
presets: [sharedConfig],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
// Project-specific overrides
},
},
};
```
@ -625,16 +619,16 @@ export default {
/* Custom utilities */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
.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;
}
.btn-primary {
@apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors;
}
}
```
@ -642,63 +636,63 @@ export default {
```svelte
<script lang="ts">
import { api } from '$lib/api/client';
import { api } from '$lib/api/client';
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
let name = $state('');
let email = $state('');
let loading = $state(false);
let errors = $state<Record<string, string>>({});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
errors = {};
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;
// 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;
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;
}
}
}
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}
{#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="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>
<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>
<button type="submit" disabled={loading} class="btn-primary">
{loading ? 'Saving...' : 'Save'}
</button>
</form>
```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,9 +23,17 @@
const weekStart = viewStore.viewRange.start;
const weekEnd = viewStore.viewRange.end;
if (weekStart.getMonth() === weekEnd.getMonth()) {
return format(weekStart, 'd.', { locale: de }) + ' - ' + format(weekEnd, 'd. MMMM yyyy', { locale: de });
return (
format(weekStart, 'd.', { locale: de }) +
' - ' +
format(weekEnd, 'd. MMMM yyyy', { locale: de })
);
}
return format(weekStart, 'd. MMM', { locale: de }) + ' - ' + format(weekEnd, 'd. MMM yyyy', { locale: de });
return (
format(weekStart, 'd. MMM', { locale: de }) +
' - ' +
format(weekEnd, 'd. MMM yyyy', { locale: de })
);
case 'month':
return format(date, 'MMMM yyyy', { locale: de });
case 'year':
@ -44,17 +52,28 @@
<header class="calendar-header">
<div class="header-left">
<button class="btn btn-ghost" onclick={() => viewStore.goToToday()}>
Heute
</button>
<button class="btn btn-ghost" onclick={() => viewStore.goToToday()}> Heute </button>
<div class="nav-buttons">
<button class="btn btn-ghost btn-icon" onclick={() => viewStore.goToPrevious()} aria-label="Zurück">
<button
class="btn btn-ghost btn-icon"
onclick={() => viewStore.goToPrevious()}
aria-label="Zurück"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick={() => viewStore.goToNext()} aria-label="Weiter">
<button
class="btn btn-ghost btn-icon"
onclick={() => viewStore.goToNext()}
aria-label="Weiter"
>
<svg class="w-5 h-5" 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" />
</svg>
@ -66,7 +85,7 @@
<div class="header-right">
<div class="view-selector">
{#each (['day', 'week', 'month'] as const) as type}
{#each ['day', 'week', 'month'] as const as type}
<button
class="view-btn"
class:active={viewStore.viewType === type}

View file

@ -3,12 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { goto } from '$app/navigation';
import {
format,
isToday,
parseISO,
differenceInMinutes,
} from 'date-fns';
import { format, isToday, parseISO, differenceInMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
let hours = Array.from({ length: 24 }, (_, i) => i);
@ -110,8 +105,14 @@
onclick={() => handleEventClick(event)}
>
<span class="event-time">
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
</span>
<span class="event-title">{event.title}</span>
{#if event.location}

View file

@ -94,17 +94,21 @@
onclick={(e) => handleEventClick(event, e)}
>
{#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>
</button>
{/each}
{#if eventsStore.getEventsForDay(day).length > 3}
<button
class="more-events"
onclick={(e) => handleMoreClick(day, e)}
>
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
+{eventsStore.getEventsForDay(day).length - 3} mehr
</button>
{/if}

View file

@ -137,7 +137,10 @@
onclick={() => handleEventClick(event)}
>
<span class="event-time">
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')}
{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)}
</span>
<span class="event-title">{event.title}</span>
</button>

View file

@ -29,7 +29,8 @@
// Initialize date/time fields
$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');
@ -92,7 +93,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}
@ -109,12 +114,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>
@ -122,12 +139,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>
@ -155,12 +184,19 @@
</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()}>
<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()}
>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>
</div>
</form>

View file

@ -57,12 +57,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
@ -79,7 +83,8 @@ export const eventsStore = {
if (!Array.isArray(currentEvents)) return [];
return currentEvents.filter((event) => {
const eventStart = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
// Check if event overlaps with the range

View file

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

View file

@ -14,7 +14,8 @@
const groups: Map<string, typeof eventsStore.events> = new Map();
for (const event of eventsStore.events) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
@ -79,9 +80,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">
@ -102,8 +101,16 @@
{#if event.isAllDay}
Ganztägig
{:else}
{format(typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, 'HH:mm')} -
{format(typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, 'HH:mm')}
{format(
typeof event.startTime === 'string'
? parseISO(event.startTime)
: event.startTime,
'HH:mm'
)} -
{format(
typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime,
'HH:mm'
)}
{/if}
</div>
<div class="event-title">{event.title}</div>

View file

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

View file

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

View file

@ -42,7 +42,9 @@
>
<svg class="w-5 h-5" 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>
@ -102,7 +104,10 @@
</div>
<div class="setting-item">
<button class="btn btn-ghost text-destructive" onclick={() => authStore.signOut().then(() => goto('/login'))}>
<button
class="btn btn-ghost text-destructive"
onclick={() => authStore.signOut().then(() => goto('/login'))}
>
Abmelden
</button>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,7 +25,10 @@
<span class="setting-description">Wähle zwischen Hell, Dunkel oder System</span>
</div>
<div class="setting-control">
<select value={theme.mode} onchange={(e) => theme.setMode((e.target as HTMLSelectElement).value as any)}>
<select
value={theme.mode}
onchange={(e) => theme.setMode((e.target as HTMLSelectElement).value as any)}
>
<option value="light">Hell</option>
<option value="dark">Dunkel</option>
<option value="system">System</option>
@ -39,7 +42,10 @@
<span class="setting-description">Wähle eine Farbpalette</span>
</div>
<div class="setting-control">
<select value={theme.variant} onchange={(e) => theme.setVariant((e.target as HTMLSelectElement).value as any)}>
<select
value={theme.variant}
onchange={(e) => theme.setVariant((e.target as HTMLSelectElement).value as any)}
>
{#each theme.variants as variant}
<option value={variant}>{THEME_DEFINITIONS[variant].label}</option>
{/each}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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