mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
🔀 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:
commit
49a8c652da
475 changed files with 28008 additions and 22742 deletions
53
apps/zitare/apps/backend/package.json
Normal file
53
apps/zitare/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
apps/zitare/apps/backend/src/app.module.ts
Normal file
20
apps/zitare/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
38
apps/zitare/apps/backend/src/db/connection.ts
Normal file
38
apps/zitare/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
29
apps/zitare/apps/backend/src/db/database.module.ts
Normal file
29
apps/zitare/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
29
apps/zitare/apps/backend/src/db/migrate.ts
Normal file
29
apps/zitare/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
17
apps/zitare/apps/backend/src/db/schema/favorites.schema.ts
Normal file
17
apps/zitare/apps/backend/src/db/schema/favorites.schema.ts
Normal 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;
|
||||
2
apps/zitare/apps/backend/src/db/schema/index.ts
Normal file
2
apps/zitare/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './favorites.schema';
|
||||
export * from './user-lists.schema';
|
||||
14
apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts
Normal file
14
apps/zitare/apps/backend/src/db/schema/user-lists.schema.ts
Normal 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;
|
||||
52
apps/zitare/apps/backend/src/favorite/favorite.controller.ts
Normal file
52
apps/zitare/apps/backend/src/favorite/favorite.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/zitare/apps/backend/src/favorite/favorite.module.ts
Normal file
10
apps/zitare/apps/backend/src/favorite/favorite.module.ts
Normal 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 {}
|
||||
34
apps/zitare/apps/backend/src/favorite/favorite.service.ts
Normal file
34
apps/zitare/apps/backend/src/favorite/favorite.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
apps/zitare/apps/backend/src/health/health.controller.ts
Normal file
13
apps/zitare/apps/backend/src/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/zitare/apps/backend/src/health/health.module.ts
Normal file
7
apps/zitare/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
99
apps/zitare/apps/backend/src/list/list.controller.ts
Normal file
99
apps/zitare/apps/backend/src/list/list.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/zitare/apps/backend/src/list/list.module.ts
Normal file
10
apps/zitare/apps/backend/src/list/list.module.ts
Normal 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 {}
|
||||
76
apps/zitare/apps/backend/src/list/list.service.ts
Normal file
76
apps/zitare/apps/backend/src/list/list.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
apps/zitare/apps/backend/src/main.ts
Normal file
40
apps/zitare/apps/backend/src/main.ts
Normal 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();
|
||||
25
apps/zitare/apps/backend/tsconfig.json
Normal file
25
apps/zitare/apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
455
apps/zitare/apps/web/src/lib/components/AuthorCard.svelte
Normal file
455
apps/zitare/apps/web/src/lib/components/AuthorCard.svelte
Normal 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>
|
||||
229
apps/zitare/apps/web/src/lib/stores/auth.svelte.ts
Normal file
229
apps/zitare/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
448
apps/zitare/apps/web/src/routes/(app)/authors/+page.svelte
Normal file
448
apps/zitare/apps/web/src/routes/(app)/authors/+page.svelte
Normal 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>
|
||||
671
apps/zitare/apps/web/src/routes/(app)/lists/+page.svelte
Normal file
671
apps/zitare/apps/web/src/routes/(app)/lists/+page.svelte
Normal 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>
|
||||
1051
apps/zitare/apps/web/src/routes/(app)/lists/[id]/+page.svelte
Normal file
1051
apps/zitare/apps/web/src/routes/(app)/lists/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
507
apps/zitare/apps/web/src/routes/(app)/search/+page.svelte
Normal file
507
apps/zitare/apps/web/src/routes/(app)/search/+page.svelte
Normal 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>
|
||||
37
apps/zitare/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
37
apps/zitare/apps/web/src/routes/(auth)/register/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue