🔀 merge: integrate till-dev into main

Merge till-dev branch containing:
- Planta plant care tracking application
- Clock backend with alarms, timers, world clocks
- Zitare backend with favorites and lists
- Various app improvements and fixes
- Auth system updates
- Infrastructure improvements

Note: Some type-check issues may need resolution after merge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-18 15:40:43 +01:00
commit 49a8c652da
475 changed files with 28008 additions and 22742 deletions

View file

@ -0,0 +1,53 @@
{
"name": "@zitare/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { FavoriteModule } from './favorite/favorite.module';
import { ListModule } from './list/list.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
FavoriteModule,
ListModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
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,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,29 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,29 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as dotenv from 'dotenv';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
dotenv.config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
const sql = postgres(databaseUrl, { max: 1 });
const db = drizzle(sql);
await migrate(db, { migrationsFolder: './src/db/migrations' });
await sql.end();
console.log('Migrations completed successfully!');
}
runMigrations().catch(console.error);

View file

@ -0,0 +1,17 @@
import { pgTable, uuid, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
export const favorites = pgTable(
'favorites',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
quoteId: varchar('quote_id', { length: 100 }).notNull(), // References static quote ID from shared package
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniqueUserQuote: unique().on(table.userId, table.quoteId),
})
);
export type Favorite = typeof favorites.$inferSelect;
export type NewFavorite = typeof favorites.$inferInsert;

View file

@ -0,0 +1,2 @@
export * from './favorites.schema';
export * from './user-lists.schema';

View file

@ -0,0 +1,14 @@
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const userLists = pgTable('user_lists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
quoteIds: jsonb('quote_ids').$type<string[]>().default([]), // References static quote IDs from shared package
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type UserList = typeof userLists.$inferSelect;
export type NewUserList = typeof userLists.$inferInsert;

View file

@ -0,0 +1,52 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
ConflictException,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { FavoriteService } from './favorite.service';
import { IsString, IsNotEmpty } from 'class-validator';
class CreateFavoriteDto {
@IsString()
@IsNotEmpty()
quoteId!: string;
}
@Controller('favorites')
@UseGuards(JwtAuthGuard)
export class FavoriteController {
constructor(private readonly favoriteService: FavoriteService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const favorites = await this.favoriteService.findByUserId(user.userId);
return { favorites };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFavoriteDto) {
// Check if already favorited
const exists = await this.favoriteService.exists(user.userId, dto.quoteId);
if (exists) {
throw new ConflictException('Quote already in favorites');
}
const favorite = await this.favoriteService.create({
userId: user.userId,
quoteId: dto.quoteId,
});
return { favorite };
}
@Delete(':quoteId')
async delete(@CurrentUser() user: CurrentUserData, @Param('quoteId') quoteId: string) {
await this.favoriteService.delete(user.userId, quoteId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FavoriteController } from './favorite.controller';
import { FavoriteService } from './favorite.service';
@Module({
controllers: [FavoriteController],
providers: [FavoriteService],
exports: [FavoriteService],
})
export class FavoriteModule {}

View file

@ -0,0 +1,34 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { favorites } from '../db/schema';
import type { Favorite, NewFavorite } from '../db/schema';
@Injectable()
export class FavoriteService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<Favorite[]> {
return this.db.select().from(favorites).where(eq(favorites.userId, userId));
}
async create(data: NewFavorite): Promise<Favorite> {
const [favorite] = await this.db.insert(favorites).values(data).returning();
return favorite;
}
async delete(userId: string, quoteId: string): Promise<void> {
await this.db
.delete(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
}
async exists(userId: string, quoteId: string): Promise<boolean> {
const result = await this.db
.select()
.from(favorites)
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
return result.length > 0;
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'quote-backend',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,99 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ListService } from './list.service';
import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator';
class CreateListDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
description?: string;
}
class UpdateListDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
quoteIds?: string[];
}
class AddQuoteDto {
@IsString()
@IsNotEmpty()
quoteId!: string;
}
@Controller('lists')
@UseGuards(JwtAuthGuard)
export class ListController {
constructor(private readonly listService: ListService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const lists = await this.listService.findByUserId(user.userId);
return { lists };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const list = await this.listService.findById(user.userId, id);
return { list };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateListDto) {
const list = await this.listService.create({
userId: user.userId,
name: dto.name,
description: dto.description,
});
return { list };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateListDto
) {
const list = await this.listService.update(user.userId, id, dto);
return { list };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.listService.delete(user.userId, id);
return { success: true };
}
@Post(':id/quotes')
async addQuote(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: AddQuoteDto
) {
const list = await this.listService.addQuoteToList(user.userId, id, dto.quoteId);
return { list };
}
@Delete(':id/quotes/:quoteId')
async removeQuote(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Param('quoteId') quoteId: string
) {
const list = await this.listService.removeQuoteFromList(user.userId, id, quoteId);
return { list };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ListController } from './list.controller';
import { ListService } from './list.service';
@Module({
controllers: [ListController],
providers: [ListService],
exports: [ListService],
})
export class ListModule {}

View file

@ -0,0 +1,76 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { userLists } from '../db/schema';
import type { UserList, NewUserList } from '../db/schema';
@Injectable()
export class ListService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<UserList[]> {
return this.db.select().from(userLists).where(eq(userLists.userId, userId));
}
async findById(userId: string, listId: string): Promise<UserList> {
const [list] = await this.db
.select()
.from(userLists)
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
if (!list) {
throw new NotFoundException('List not found');
}
return list;
}
async create(data: NewUserList): Promise<UserList> {
const [list] = await this.db.insert(userLists).values(data).returning();
return list;
}
async update(
userId: string,
listId: string,
data: Partial<Pick<UserList, 'name' | 'description' | 'quoteIds'>>
): Promise<UserList> {
const [list] = await this.db
.update(userLists)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)))
.returning();
if (!list) {
throw new NotFoundException('List not found');
}
return list;
}
async delete(userId: string, listId: string): Promise<void> {
const result = await this.db
.delete(userLists)
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
if (!result) {
throw new NotFoundException('List not found');
}
}
async addQuoteToList(userId: string, listId: string, quoteId: string): Promise<UserList> {
const list = await this.findById(userId, listId);
const quoteIds = list.quoteIds || [];
if (!quoteIds.includes(quoteId)) {
quoteIds.push(quoteId);
}
return this.update(userId, listId, { quoteIds });
}
async removeQuoteFromList(userId: string, listId: string, quoteId: string): Promise<UserList> {
const list = await this.findById(userId, listId);
const quoteIds = (list.quoteIds || []).filter((id) => id !== quoteId);
return this.update(userId, listId, { quoteIds });
}
}

View file

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5177',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3007;
await app.listen(port);
console.log(`Quote backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,455 @@
<script lang="ts">
import type { Author } from '@zitare/shared';
import { createEventDispatcher } from 'svelte';
interface Props {
author: Author & { quoteCount?: number };
variant?: 'simple' | 'enhanced';
isFavorite?: boolean;
}
let { author, variant = 'enhanced', isFavorite = false }: Props = $props();
const dispatch = createEventDispatcher();
// Get gradient colors based on profession
function getGradientColors(author: Author): string {
if (author.featured) {
return 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)'; // Amber to Red for featured
}
const profession = author.profession?.[0]?.toLowerCase() || '';
if (profession.includes('philosoph')) {
return 'linear-gradient(135deg, #9333ea 0%, #6366f1 100%)'; // Purple to Indigo
} else if (profession.includes('dichter') || profession.includes('poet')) {
return 'linear-gradient(135deg, #ec4899 0%, #f43f5e 100%)'; // Pink to Rose
} else if (profession.includes('wissenschaft')) {
return 'linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%)'; // Blue to Cyan
} else if (profession.includes('schrift')) {
return 'linear-gradient(135deg, #10b981 0%, #14b8a6 100%)'; // Emerald to Teal
}
return 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'; // Default: Indigo to Violet
}
function getLifeYears(): string | null {
if (!author.lifespan) return null;
const birth = author.lifespan.birth?.substring(0, 4);
const death = author.lifespan.death?.substring(0, 4);
if (birth && death) {
return `${birth} ${death}`;
}
if (birth) {
return `Born ${birth}`;
}
return null;
}
function handleCopy() {
const lifeYears = getLifeYears();
const text = `${author.name}${lifeYears ? ` (${lifeYears})` : ''}\n${author.profession?.join(', ') || ''}`;
navigator.clipboard.writeText(text);
dispatch('copy', { author });
showCopyFeedback();
}
function handleShare() {
const lifeYears = getLifeYears();
const authorInfo = `${author.name}${lifeYears ? ` (${lifeYears})` : ''}\n${author.profession?.join(', ') || ''}\n\n${author.biography?.short || ''}`;
if (navigator.share) {
navigator
.share({
title: author.name,
text: authorInfo,
})
.catch(() => {
handleCopy();
});
} else {
handleCopy();
}
dispatch('share', { author });
}
function handleFavorite() {
dispatch('toggleFavorite', { authorId: author.id });
}
function handleClick(e: MouseEvent) {
// Only navigate if not clicking action buttons
if (!(e.target as HTMLElement).closest('.action-btn')) {
dispatch('click', { author });
}
}
let showCopySuccess = $state(false);
function showCopyFeedback() {
showCopySuccess = true;
setTimeout(() => {
showCopySuccess = false;
}, 2000);
}
const gradientStyle = getGradientColors(author);
const lifeYears = getLifeYears();
</script>
<article
class="author-card"
class:enhanced={variant === 'enhanced'}
class:simple={variant === 'simple'}
style="background: {gradientStyle}"
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick(e as any)}
>
<div class="card-inner">
<!-- Main Content -->
<div class="author-header">
<!-- Avatar -->
<div class="author-avatar">
{author.name.charAt(0)}
</div>
<!-- Author Info -->
<div class="author-info">
<h3 class="author-name">{author.name}</h3>
{#if lifeYears}
<p class="lifespan">{lifeYears}</p>
{/if}
</div>
<!-- Arrow -->
<div class="arrow">
<svg
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</div>
</div>
<!-- Biography -->
{#if author.biography?.short}
<div class="bio-section">
<p class="bio">{author.biography.short}</p>
</div>
{/if}
<!-- Professions and Action Buttons -->
<div class="footer-section">
<!-- Professions -->
<div class="professions">
{#if author.profession && author.profession.length > 0}
{#each author.profession.slice(0, 2) as profession}
<span class="profession-tag">{profession}</span>
{/each}
{#if author.profession.length > 2}
<span class="profession-more">+{author.profession.length - 2}</span>
{/if}
{/if}
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<!-- Copy Button -->
<button
class="action-btn"
onclick={(e) => {
e.stopPropagation();
handleCopy();
}}
title="Copy author info"
aria-label="Copy author info to clipboard"
>
{#if showCopySuccess}
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
{/if}
</button>
<!-- Share Button -->
<button
class="action-btn"
onclick={(e) => {
e.stopPropagation();
handleShare();
}}
title="Share author"
aria-label="Share author"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
</button>
<!-- Favorite Button -->
<button
class="action-btn favorite-btn"
class:is-favorite={isFavorite}
onclick={(e) => {
e.stopPropagation();
handleFavorite();
}}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
{#if isFavorite}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<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
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
</article>
<style>
.author-card {
position: relative;
border-radius: 24px;
padding: 1.5px;
transition:
transform var(--transition-base),
box-shadow var(--transition-base);
cursor: pointer;
}
.author-card:hover {
transform: scale(0.98);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
}
.card-inner {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: calc(24px - 1.5px);
padding: var(--spacing-lg);
}
/* Author Header */
.author-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.author-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
font-weight: bold;
flex-shrink: 0;
}
.author-info {
flex: 1;
min-width: 0;
}
.author-name {
margin: 0 0 4px 0;
font-size: 1.125rem;
font-weight: 500;
color: white;
}
.lifespan {
margin: 0;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
}
.arrow {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
/* Bio Section */
.bio-section {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-top: var(--spacing-md);
padding-top: var(--spacing-sm);
}
.bio {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Footer Section */
.footer-section {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: var(--spacing-sm);
gap: var(--spacing-md);
}
.professions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.profession-tag {
display: inline-block;
padding: var(--spacing-xs) var(--spacing-sm);
background: rgba(255, 255, 255, 0.1);
color: white;
border-radius: var(--radius-full);
font-size: 0.75rem;
opacity: 0.7;
white-space: nowrap;
}
.profession-more {
align-self: center;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: var(--spacing-sm);
align-items: center;
flex-shrink: 0;
}
.action-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
}
.action-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
.action-btn:active {
transform: scale(0.95);
}
.favorite-btn.is-favorite {
color: #ff6b9d;
}
.favorite-btn.is-favorite:hover {
color: #ff4081;
}
/* Responsive */
@media (max-width: 768px) {
.author-avatar {
width: 48px;
height: 48px;
font-size: 1.25rem;
}
.author-name {
font-size: 1rem;
}
.action-btn svg {
width: 20px;
height: 20px;
}
}
</style>

View file

@ -0,0 +1,229 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3007';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3007';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
return _tokenManager;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls (raw token, no refresh)
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
/**
* Get a valid access token for API calls
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) {
return null;
}
return await tokenManager.getValidToken();
},
};

View file

@ -0,0 +1,448 @@
<script lang="ts">
import { authorsDE, quotesDE } from '@zitare/shared';
import type { Author } from '@zitare/shared';
import { PageHeader } from '@manacore/shared-ui';
import AuthorCard from '$lib/components/AuthorCard.svelte';
// Get quote counts for each author
const authorsWithQuotes = authorsDE
.map((author) => {
const quoteCount = quotesDE.filter((q) => q.authorId === author.id).length;
return { ...author, quoteCount };
})
.sort((a, b) => b.quoteCount - a.quoteCount);
let searchTerm = $state('');
let favorites = $state<Set<string>>(new Set());
let isSearchOpen = $state(false);
// Pagination state
const ITEMS_PER_PAGE = 20;
let currentPage = $state(1);
let isLoadingMore = $state(false);
// Load favorites from localStorage
if (typeof window !== 'undefined') {
const savedFavorites = localStorage.getItem('authorFavorites');
if (savedFavorites) {
favorites = new Set(JSON.parse(savedFavorites));
}
}
// Filter authors by search term (all matching authors)
let allFilteredAuthors = $derived(
authorsWithQuotes
.map((author) => ({
...author,
isFavorite: favorites.has(author.id),
}))
.filter(
(author) =>
author.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
author.profession?.some((p) => p.toLowerCase().includes(searchTerm.toLowerCase()))
)
);
// Paginated authors (only show what should be visible)
let filteredAuthors = $derived(allFilteredAuthors.slice(0, currentPage * ITEMS_PER_PAGE));
// Check if there are more items to load
let hasMore = $derived(filteredAuthors.length < allFilteredAuthors.length);
function toggleSearch() {
isSearchOpen = !isSearchOpen;
if (!isSearchOpen) {
searchTerm = '';
currentPage = 1;
}
}
function loadMore() {
isLoadingMore = true;
setTimeout(() => {
currentPage++;
isLoadingMore = false;
}, 300);
}
// Reset page when search changes
$effect(() => {
searchTerm;
currentPage = 1;
});
function handleToggleFavorite(event: CustomEvent) {
const { authorId } = event.detail;
if (favorites.has(authorId)) {
favorites.delete(authorId);
} else {
favorites.add(authorId);
}
favorites = new Set(favorites);
// Save to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('authorFavorites', JSON.stringify([...favorites]));
}
}
function handleAuthorClick(event: CustomEvent) {
const { author } = event.detail;
window.location.href = `/authors/${author.id}`;
}
</script>
<svelte:head>
<title>Autoren - Zitare</title>
<meta name="description" content="Durchsuche alle Autoren und ihre Zitate" />
</svelte:head>
<div class="authors-page">
<div class="header-container">
<PageHeader title="Autoren" size="lg">
{#snippet actions()}
<button class="search-fab" onclick={toggleSearch} aria-label="Toggle search">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{#if isSearchOpen}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
{:else}
<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"
/>
{/if}
</svg>
</button>
{/snippet}
</PageHeader>
{#if isSearchOpen}
<div class="search-bar">
<input type="text" placeholder="Search authors..." bind:value={searchTerm} class="search" />
</div>
{/if}
</div>
{#if allFilteredAuthors.length === 0 && searchTerm}
<!-- Empty Search Results -->
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Autoren gefunden</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<div class="authors-grid">
{#each filteredAuthors as author (author.id)}
<AuthorCard
{author}
isFavorite={author.isFavorite}
on:click={handleAuthorClick}
on:toggleFavorite={handleToggleFavorite}
/>
{/each}
</div>
<!-- Load More Button -->
{#if hasMore}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore}
<svg
class="spinner"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="10" stroke-width="3" stroke-opacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10" stroke-width="3" stroke-linecap="round"></path>
</svg>
Laden...
{:else}
Mehr laden ({allFilteredAuthors.length - filteredAuthors.length} weitere)
{/if}
</button>
</div>
{/if}
{/if}
{#if isSearchOpen}
<div class="floating-results">
{allFilteredAuthors.length} von {authorsDE.length} Autoren
{#if filteredAuthors.length < allFilteredAuthors.length}
{filteredAuthors.length} angezeigt
{/if}
</div>
{/if}
</div>
<style>
.authors-page {
max-width: 1200px;
margin: 0 auto;
position: relative;
padding-bottom: var(--spacing-2xl);
}
.header-container {
max-width: 700px;
margin: 0 auto var(--spacing-xl);
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: 2rem;
margin: 0;
color: rgb(var(--color-text-primary));
}
.search-fab {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 9999px;
background: rgb(var(--color-primary));
color: white;
border: none;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
}
.search-fab:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.search-fab:active {
transform: scale(0.95);
}
.search-bar {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: rgb(var(--color-surface));
border-radius: var(--radius-lg);
border: 1px solid rgb(var(--color-border));
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.authors-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
max-width: 700px;
margin: 0 auto;
}
.floating-results {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: var(--spacing-sm) var(--spacing-lg);
background: rgba(var(--color-surface), 0.95);
backdrop-filter: blur(10px);
border-radius: var(--radius-full);
border: 1px solid rgba(var(--color-border), 0.5);
box-shadow: var(--shadow-lg);
color: rgb(var(--color-text-secondary));
font-size: 0.875rem;
font-weight: 500;
z-index: 20;
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translate(-50%, 10px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Empty State */
.empty-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
/* Load More Button */
.load-more-container {
max-width: 700px;
margin: var(--spacing-xl) auto 0;
text-align: center;
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-2xl);
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-full);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
}
.load-more-btn:hover:not(:disabled) {
background: rgb(var(--color-primary));
color: white;
border-color: rgb(var(--color-primary));
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.authors-page {
padding-bottom: var(--spacing-xl);
}
.header-container {
max-width: 100%;
margin-bottom: var(--spacing-lg);
}
.header-row {
margin-bottom: var(--spacing-md);
}
h2 {
font-size: 1.5rem;
}
.search-fab {
width: 2.5rem;
height: 2.5rem;
}
.search-bar {
padding: var(--spacing-sm);
}
.authors-grid {
gap: var(--spacing-lg);
max-width: 100%;
}
.floating-results {
bottom: 5rem; /* Above mobile bottom nav */
font-size: 0.8125rem;
padding: var(--spacing-xs) var(--spacing-md);
}
.empty-state {
padding: var(--spacing-xl);
}
.empty-state h3 {
font-size: 1.25rem;
}
.empty-state p {
font-size: 0.9375rem;
}
}
</style>

View file

@ -0,0 +1,671 @@
<script lang="ts">
import { listsStore } from '$lib/stores/lists';
import type { QuoteList } from '$lib/stores/lists';
import { quotesDE } from '@zitare/shared';
import { PageHeader } from '@manacore/shared-ui';
import { toast } from '$lib/stores/toast';
let lists = $state<QuoteList[]>([]);
let showCreateModal = $state(false);
let newListName = $state('');
let newListDescription = $state('');
let searchTerm = $state('');
// Subscribe to lists store
listsStore.subscribe((value) => {
lists = value;
});
// Filter lists by search term
let filteredLists = $derived(
lists.filter(
(list) =>
list.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
list.description?.toLowerCase().includes(searchTerm.toLowerCase())
)
);
function openCreateModal() {
showCreateModal = true;
newListName = '';
newListDescription = '';
}
function closeCreateModal() {
showCreateModal = false;
newListName = '';
newListDescription = '';
}
function handleCreateList() {
if (newListName.trim()) {
listsStore.createList(newListName.trim(), newListDescription.trim() || undefined);
toast.success('Liste erstellt!');
closeCreateModal();
}
}
function handleDeleteList(listId: string) {
if (confirm('Möchtest du diese Liste wirklich löschen?')) {
listsStore.deleteList(listId);
toast.info('Liste gelöscht');
}
}
function getQuoteCount(quoteIds: string[]): number {
return quoteIds.length;
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<svelte:head>
<title>Listen - Zitare</title>
</svelte:head>
<div class="lists-page">
<div class="header-container">
<PageHeader
title="Meine Listen"
description="{lists.length} {lists.length === 1 ? 'Liste' : 'Listen'}"
size="lg"
>
{#snippet actions()}
<button class="create-fab" onclick={openCreateModal} aria-label="Neue Liste erstellen">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
{/snippet}
</PageHeader>
{#if lists.length > 3}
<div class="search-container">
<input
type="text"
placeholder="Listen durchsuchen..."
bind:value={searchTerm}
class="search"
/>
</div>
{/if}
</div>
{#if lists.length === 0}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</div>
<h3>Keine Listen</h3>
<p>Erstelle deine erste Liste, um Zitate zu organisieren</p>
<button class="cta-button" onclick={openCreateModal}>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Erste Liste erstellen
</button>
</div>
{:else if filteredLists.length === 0}
<!-- No Search Results -->
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Ergebnisse</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<div class="lists-grid">
{#each filteredLists as list (list.id)}
<a href="/lists/{list.id}" class="list-card">
<div class="list-header">
<h3>{list.name}</h3>
<button
class="delete-btn"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteList(list.id);
}}
aria-label="Liste löschen"
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{#if list.description}
<p class="list-description">{list.description}</p>
{/if}
<div class="list-meta">
<div class="meta-item">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
<span>{getQuoteCount(list.quoteIds)} Zitate</span>
</div>
<div class="meta-item">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{formatDate(list.updatedAt)}</span>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create List Modal -->
{#if showCreateModal}
<div class="modal-overlay" onclick={closeCreateModal}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<h3>Neue Liste erstellen</h3>
<button class="close-btn" onclick={closeCreateModal} aria-label="Schließen">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="list-name">Name *</label>
<input
id="list-name"
type="text"
bind:value={newListName}
placeholder="z.B. Motivierende Zitate"
class="form-input"
maxlength="50"
/>
</div>
<div class="form-group">
<label for="list-description">Beschreibung (optional)</label>
<textarea
id="list-description"
bind:value={newListDescription}
placeholder="Was macht diese Liste besonders?"
class="form-textarea"
rows="3"
maxlength="200"
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={closeCreateModal}> Abbrechen </button>
<button class="btn btn-primary" onclick={handleCreateList} disabled={!newListName.trim()}>
Erstellen
</button>
</div>
</div>
</div>
{/if}
<style>
.lists-page {
max-width: 1200px;
margin: 0 auto;
padding-bottom: var(--spacing-2xl);
}
.header-container {
max-width: 900px;
margin: 0 auto var(--spacing-xl);
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: 2rem;
margin: 0 0 var(--spacing-xs) 0;
color: rgb(var(--color-text-primary));
}
.subtitle {
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
.create-fab {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 9999px;
background: rgb(var(--color-primary));
color: white;
border: none;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
flex-shrink: 0;
}
.create-fab:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.create-fab:active {
transform: scale(0.95);
}
.search-container {
margin-top: var(--spacing-md);
}
.search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.lists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-lg);
max-width: 900px;
margin: 0 auto;
}
.list-card {
background: rgb(var(--color-surface));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
text-decoration: none;
color: inherit;
transition: all var(--transition-base);
display: block;
}
.list-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: rgb(var(--color-primary));
}
.list-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.list-card h3 {
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
margin: 0;
flex: 1;
}
.delete-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-tertiary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.delete-btn:hover {
color: rgb(var(--color-error));
background: rgba(var(--color-error), 0.1);
}
.list-description {
color: rgb(var(--color-text-secondary));
font-size: 0.9375rem;
margin: 0 0 var(--spacing-md) 0;
line-height: 1.5;
}
.list-meta {
display: flex;
gap: var(--spacing-lg);
padding-top: var(--spacing-sm);
border-top: 1px solid rgb(var(--color-border));
}
.meta-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
}
.meta-item svg {
color: rgb(var(--color-text-tertiary));
}
/* Empty State */
.empty-state {
max-width: 500px;
margin: var(--spacing-2xl) auto;
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0 0 var(--spacing-xl) 0;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-xl);
background: rgb(var(--color-primary));
color: white;
border: none;
border-radius: var(--radius-full);
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: var(--spacing-lg);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: rgb(var(--color-surface-elevated));
border-radius: var(--radius-xl);
max-width: 500px;
width: 100%;
box-shadow: var(--shadow-xl);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid rgb(var(--color-border));
}
.modal-header h3 {
font-size: 1.25rem;
margin: 0;
color: rgb(var(--color-text-primary));
}
.close-btn {
background: none;
border: none;
padding: var(--spacing-xs);
cursor: pointer;
color: rgb(var(--color-text-secondary));
transition: all var(--transition-fast);
border-radius: var(--radius-sm);
}
.close-btn:hover {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
}
.modal-body {
padding: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-text-primary));
margin-bottom: var(--spacing-xs);
}
.form-input,
.form-textarea {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 1rem;
background: rgb(var(--color-background));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
font-family: inherit;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
padding: var(--spacing-lg);
border-top: 1px solid rgb(var(--color-border));
}
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
transition: all var(--transition-base);
border: none;
}
.btn-secondary {
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 1px solid rgb(var(--color-border));
}
.btn-secondary:hover {
background: rgb(var(--color-background));
}
.btn-primary {
background: rgb(var(--color-primary));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.lists-page {
padding-bottom: var(--spacing-xl);
}
.header-container {
max-width: 100%;
}
h2 {
font-size: 1.5rem;
}
.create-fab {
width: 2.5rem;
height: 2.5rem;
}
.lists-grid {
grid-template-columns: 1fr;
max-width: 100%;
}
.empty-state {
padding: var(--spacing-xl);
}
.modal {
margin: var(--spacing-md);
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,507 @@
<script lang="ts">
import { quotesDE, authorsDE } from '@zitare/shared';
import type { Quote, Author } from '@zitare/shared';
import QuoteCard from '$lib/components/QuoteCard.svelte';
import AuthorCard from '$lib/components/AuthorCard.svelte';
let searchTerm = $state('');
let activeTab = $state<'all' | 'quotes' | 'authors'>('all');
let favorites = $state<Set<string>>(new Set());
let authorFavorites = $state<Set<string>>(new Set());
// Pagination
const ITEMS_PER_PAGE = 20;
let currentPage = $state(1);
// Load favorites from localStorage
if (typeof window !== 'undefined') {
const savedFavorites = localStorage.getItem('favorites');
if (savedFavorites) {
favorites = new Set(JSON.parse(savedFavorites));
}
const savedAuthorFavorites = localStorage.getItem('authorFavorites');
if (savedAuthorFavorites) {
authorFavorites = new Set(JSON.parse(savedAuthorFavorites));
}
}
// Search results
let filteredQuotes = $derived(
searchTerm.length >= 2
? quotesDE
.filter(
(q) =>
q.text.toLowerCase().includes(searchTerm.toLowerCase()) ||
authorsDE
.find((a) => a.id === q.authorId)
?.name.toLowerCase()
.includes(searchTerm.toLowerCase())
)
.map((q) => ({
...q,
author: authorsDE.find((a) => a.id === q.authorId),
isFavorite: favorites.has(q.id),
}))
: []
);
let filteredAuthors = $derived(
searchTerm.length >= 2
? authorsDE
.filter(
(a) =>
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
a.profession?.some((p) => p.toLowerCase().includes(searchTerm.toLowerCase()))
)
.map((a) => ({
...a,
quoteCount: quotesDE.filter((q) => q.authorId === a.id).length,
isFavorite: authorFavorites.has(a.id),
}))
: []
);
// Paginated results
let displayedQuotes = $derived(filteredQuotes.slice(0, currentPage * ITEMS_PER_PAGE));
let displayedAuthors = $derived(filteredAuthors.slice(0, currentPage * ITEMS_PER_PAGE));
// Total results
let totalResults = $derived(
activeTab === 'quotes'
? filteredQuotes.length
: activeTab === 'authors'
? filteredAuthors.length
: filteredQuotes.length + filteredAuthors.length
);
let hasMoreQuotes = $derived(displayedQuotes.length < filteredQuotes.length);
let hasMoreAuthors = $derived(displayedAuthors.length < filteredAuthors.length);
// Reset page when search or tab changes
$effect(() => {
searchTerm;
activeTab;
currentPage = 1;
});
function loadMore() {
currentPage++;
}
function handleToggleFavorite(event: CustomEvent) {
const { quoteId } = event.detail;
if (favorites.has(quoteId)) {
favorites.delete(quoteId);
} else {
favorites.add(quoteId);
}
favorites = new Set(favorites);
if (typeof window !== 'undefined') {
localStorage.setItem('favorites', JSON.stringify([...favorites]));
}
}
function handleAuthorToggleFavorite(event: CustomEvent) {
const { authorId } = event.detail;
if (authorFavorites.has(authorId)) {
authorFavorites.delete(authorId);
} else {
authorFavorites.add(authorId);
}
authorFavorites = new Set(authorFavorites);
if (typeof window !== 'undefined') {
localStorage.setItem('authorFavorites', JSON.stringify([...authorFavorites]));
}
}
function handleAuthorClick(event: CustomEvent) {
const { author, authorId } = event.detail;
const id = author?.id || authorId;
if (id) {
window.location.href = `/authors/${id}`;
}
}
</script>
<svelte:head>
<title>Suche - Zitare</title>
<meta name="description" content="Durchsuche Zitate und Autoren" />
</svelte:head>
<div class="search-page">
<div class="search-header">
<h2>Suche</h2>
<div class="search-input-wrapper">
<svg
class="search-icon"
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
placeholder="Zitate oder Autoren suchen..."
bind:value={searchTerm}
class="search-input"
autofocus
/>
{#if searchTerm}
<button class="clear-btn" onclick={() => (searchTerm = '')} aria-label="Clear search">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
{#if searchTerm.length >= 2}
<!-- Tabs -->
<div class="tabs">
<button class="tab" class:active={activeTab === 'all'} onclick={() => (activeTab = 'all')}>
Alle ({filteredQuotes.length + filteredAuthors.length})
</button>
<button
class="tab"
class:active={activeTab === 'quotes'}
onclick={() => (activeTab = 'quotes')}
>
Zitate ({filteredQuotes.length})
</button>
<button
class="tab"
class:active={activeTab === 'authors'}
onclick={() => (activeTab = 'authors')}
>
Autoren ({filteredAuthors.length})
</button>
</div>
{#if totalResults === 0}
<div class="empty-state">
<div class="empty-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<h3>Keine Ergebnisse</h3>
<p>Versuche es mit anderen Suchbegriffen</p>
</div>
{:else}
<!-- Results -->
<div class="results">
<!-- Quotes Section -->
{#if (activeTab === 'all' || activeTab === 'quotes') && displayedQuotes.length > 0}
{#if activeTab === 'all'}
<h3 class="section-title">Zitate ({filteredQuotes.length})</h3>
{/if}
<div class="quotes-list">
{#each displayedQuotes as quote (quote.id)}
<QuoteCard
{quote}
on:toggleFavorite={handleToggleFavorite}
on:authorClick={handleAuthorClick}
/>
{/each}
</div>
{#if activeTab === 'quotes' && hasMoreQuotes}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore}>
Mehr laden ({filteredQuotes.length - displayedQuotes.length} weitere)
</button>
</div>
{/if}
{/if}
<!-- Authors Section -->
{#if (activeTab === 'all' || activeTab === 'authors') && displayedAuthors.length > 0}
{#if activeTab === 'all'}
<h3 class="section-title">Autoren ({filteredAuthors.length})</h3>
{/if}
<div class="authors-list">
{#each displayedAuthors as author (author.id)}
<AuthorCard
{author}
isFavorite={author.isFavorite}
on:click={handleAuthorClick}
on:toggleFavorite={handleAuthorToggleFavorite}
/>
{/each}
</div>
{#if activeTab === 'authors' && hasMoreAuthors}
<div class="load-more-container">
<button class="load-more-btn" onclick={loadMore}>
Mehr laden ({filteredAuthors.length - displayedAuthors.length} weitere)
</button>
</div>
{/if}
{/if}
</div>
{/if}
{:else if searchTerm.length > 0}
<div class="hint">
<p>Bitte gib mindestens 2 Zeichen ein</p>
</div>
{:else}
<div class="hint">
<div class="hint-icon">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</div>
<p>Suche nach Zitaten, Autoren oder Themen</p>
</div>
{/if}
</div>
<style>
.search-page {
max-width: 700px;
margin: 0 auto;
padding-bottom: var(--spacing-2xl);
}
.search-header {
margin-bottom: var(--spacing-xl);
}
h2 {
font-size: 2rem;
margin: 0 0 var(--spacing-lg) 0;
color: rgb(var(--color-text-primary));
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 1rem;
color: rgb(var(--color-text-tertiary));
pointer-events: none;
}
.search-input {
width: 100%;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) 3rem;
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-lg);
font-size: 1rem;
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
transition: border-color var(--transition-fast);
}
.search-input:focus {
outline: none;
border-color: rgb(var(--color-primary));
}
.clear-btn {
position: absolute;
right: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: rgb(var(--color-text-tertiary));
cursor: pointer;
border-radius: var(--radius-full);
transition: all var(--transition-fast);
}
.clear-btn:hover {
background: rgb(var(--color-border));
color: rgb(var(--color-text-primary));
}
/* Tabs */
.tabs {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xl);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
padding: var(--spacing-sm) var(--spacing-lg);
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-full);
background: rgb(var(--color-surface));
color: rgb(var(--color-text-secondary));
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
}
.tab:hover {
border-color: rgb(var(--color-primary));
color: rgb(var(--color-text-primary));
}
.tab.active {
background: rgb(var(--color-primary));
border-color: rgb(var(--color-primary));
color: white;
}
/* Results */
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.section-title {
font-size: 1.25rem;
color: rgb(var(--color-text-primary));
margin: 0;
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid rgb(var(--color-border));
}
.quotes-list,
.authors-list {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
/* Empty State */
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
}
.empty-icon {
margin: 0 auto var(--spacing-lg);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
color: rgb(var(--color-text-primary));
margin: 0 0 var(--spacing-sm) 0;
}
.empty-state p {
font-size: 1rem;
color: rgb(var(--color-text-secondary));
margin: 0;
}
/* Hint */
.hint {
text-align: center;
padding: var(--spacing-2xl);
color: rgb(var(--color-text-secondary));
}
.hint-icon {
margin-bottom: var(--spacing-md);
color: rgb(var(--color-text-tertiary));
opacity: 0.5;
}
.hint p {
margin: 0;
font-size: 1rem;
}
/* Load More */
.load-more-container {
text-align: center;
margin-top: var(--spacing-lg);
}
.load-more-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-2xl);
background: rgb(var(--color-surface));
color: rgb(var(--color-text-primary));
border: 2px solid rgb(var(--color-border));
border-radius: var(--radius-full);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
}
.load-more-btn:hover {
background: rgb(var(--color-primary));
color: white;
border-color: rgb(var(--color-primary));
}
@media (max-width: 768px) {
.search-page {
padding-bottom: var(--spacing-xl);
}
h2 {
font-size: 1.5rem;
}
.search-input {
padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm) 2.5rem;
}
.tabs {
margin-bottom: var(--spacing-lg);
}
.tab {
padding: var(--spacing-xs) var(--spacing-md);
font-size: 0.8125rem;
}
}
</style>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { ZitareLogo } from '@manacore/shared-branding';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>{translations.title} - Zitare</title>
</svelte:head>
<RegisterPage
appName="Zitare"
logo={ZitareLogo}
primaryColor="#f59e0b"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#fffbeb"
darkBackground="#1c1917"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
</RegisterPage>