style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -1,12 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/quote',
},
verbose: true,
strict: true,
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/quote',
},
verbose: true,
strict: true,
});

View file

@ -1,10 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [],
"watchAssets": false
}
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [],
"watchAssets": false
}
}

View file

@ -1,52 +1,52 @@
{
"name": "@quote/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": {
"@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"
}
"name": "@quote/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": {
"@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

@ -6,15 +6,15 @@ import { ListModule } from './list/list.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
FavoriteModule,
ListModule,
HealthModule,
],
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
FavoriteModule,
ListModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -9,30 +9,30 @@ let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
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;
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -6,23 +6,23 @@ 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],
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();
}
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -8,22 +8,22 @@ const postgres = require('postgres');
dotenv.config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
console.log('Running migrations...');
const sql = postgres(databaseUrl, { max: 1 });
const db = drizzle(sql);
const sql = postgres(databaseUrl, { max: 1 });
const db = drizzle(sql);
await migrate(db, { migrationsFolder: './src/db/migrations' });
await migrate(db, { migrationsFolder: './src/db/migrations' });
await sql.end();
await sql.end();
console.log('Migrations completed successfully!');
console.log('Migrations completed successfully!');
}
runMigrations().catch(console.error);

View file

@ -1,13 +1,17 @@
import { pgTable, uuid, timestamp, unique, varchar } from 'drizzle-orm/pg-core';
export const favorites = pgTable('favorites', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('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 const favorites = pgTable(
'favorites',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('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

@ -1,13 +1,13 @@
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
export const userLists = pgTable('user_lists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('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(),
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('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;

View file

@ -1,79 +1,73 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Headers,
UnauthorizedException,
ConflictException,
Controller,
Get,
Post,
Delete,
Body,
Param,
Headers,
UnauthorizedException,
ConflictException,
} from '@nestjs/common';
import { FavoriteService } from './favorite.service';
import { IsString, IsNotEmpty } from 'class-validator';
class CreateFavoriteDto {
@IsString()
@IsNotEmpty()
quoteId!: string;
@IsString()
@IsNotEmpty()
quoteId!: string;
}
// Simple JWT extraction - in production, use proper auth middleware
function extractUserId(authHeader?: string): string {
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
try {
const token = authHeader.substring(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload.sub) {
throw new UnauthorizedException('Invalid token payload');
}
return payload.sub;
} catch {
throw new UnauthorizedException('Invalid token');
}
try {
const token = authHeader.substring(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload.sub) {
throw new UnauthorizedException('Invalid token payload');
}
return payload.sub;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
@Controller('favorites')
export class FavoriteController {
constructor(private readonly favoriteService: FavoriteService) {}
constructor(private readonly favoriteService: FavoriteService) {}
@Get()
async findAll(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
const favorites = await this.favoriteService.findByUserId(userId);
return { favorites };
}
@Get()
async findAll(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
const favorites = await this.favoriteService.findByUserId(userId);
return { favorites };
}
@Post()
async create(
@Headers('authorization') authHeader: string,
@Body() dto: CreateFavoriteDto,
) {
const userId = extractUserId(authHeader);
@Post()
async create(@Headers('authorization') authHeader: string, @Body() dto: CreateFavoriteDto) {
const userId = extractUserId(authHeader);
// Check if already favorited
const exists = await this.favoriteService.exists(userId, dto.quoteId);
if (exists) {
throw new ConflictException('Quote already in favorites');
}
// Check if already favorited
const exists = await this.favoriteService.exists(userId, dto.quoteId);
if (exists) {
throw new ConflictException('Quote already in favorites');
}
const favorite = await this.favoriteService.create({
userId,
quoteId: dto.quoteId,
});
return { favorite };
}
const favorite = await this.favoriteService.create({
userId,
quoteId: dto.quoteId,
});
return { favorite };
}
@Delete(':quoteId')
async delete(
@Headers('authorization') authHeader: string,
@Param('quoteId') quoteId: string,
) {
const userId = extractUserId(authHeader);
await this.favoriteService.delete(userId, quoteId);
return { success: true };
}
@Delete(':quoteId')
async delete(@Headers('authorization') authHeader: string, @Param('quoteId') quoteId: string) {
const userId = extractUserId(authHeader);
await this.favoriteService.delete(userId, quoteId);
return { success: true };
}
}

View file

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

View file

@ -6,28 +6,28 @@ import { favorites, type Favorite, type NewFavorite } from '../db/schema';
@Injectable()
export class FavoriteService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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 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 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 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;
}
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

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

View file

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

View file

@ -1,141 +1,132 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Headers,
UnauthorizedException,
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Headers,
UnauthorizedException,
} from '@nestjs/common';
import { ListService } from './list.service';
import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator';
class CreateListDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
description?: string;
}
class UpdateListDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
description?: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
quoteIds?: string[];
@IsArray()
@IsString({ each: true })
@IsOptional()
quoteIds?: string[];
}
class AddQuoteDto {
@IsString()
@IsNotEmpty()
quoteId!: string;
@IsString()
@IsNotEmpty()
quoteId!: string;
}
// Simple JWT extraction - in production, use proper auth middleware
function extractUserId(authHeader?: string): string {
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
try {
const token = authHeader.substring(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload.sub) {
throw new UnauthorizedException('Invalid token payload');
}
return payload.sub;
} catch {
throw new UnauthorizedException('Invalid token');
}
try {
const token = authHeader.substring(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload.sub) {
throw new UnauthorizedException('Invalid token payload');
}
return payload.sub;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
@Controller('lists')
export class ListController {
constructor(private readonly listService: ListService) {}
constructor(private readonly listService: ListService) {}
@Get()
async findAll(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
const lists = await this.listService.findByUserId(userId);
return { lists };
}
@Get()
async findAll(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
const lists = await this.listService.findByUserId(userId);
return { lists };
}
@Get(':id')
async findOne(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
) {
const userId = extractUserId(authHeader);
const list = await this.listService.findById(userId, id);
return { list };
}
@Get(':id')
async findOne(@Headers('authorization') authHeader: string, @Param('id') id: string) {
const userId = extractUserId(authHeader);
const list = await this.listService.findById(userId, id);
return { list };
}
@Post()
async create(
@Headers('authorization') authHeader: string,
@Body() dto: CreateListDto,
) {
const userId = extractUserId(authHeader);
const list = await this.listService.create({
userId,
name: dto.name,
description: dto.description,
});
return { list };
}
@Post()
async create(@Headers('authorization') authHeader: string, @Body() dto: CreateListDto) {
const userId = extractUserId(authHeader);
const list = await this.listService.create({
userId,
name: dto.name,
description: dto.description,
});
return { list };
}
@Put(':id')
async update(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
@Body() dto: UpdateListDto,
) {
const userId = extractUserId(authHeader);
const list = await this.listService.update(userId, id, dto);
return { list };
}
@Put(':id')
async update(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
@Body() dto: UpdateListDto
) {
const userId = extractUserId(authHeader);
const list = await this.listService.update(userId, id, dto);
return { list };
}
@Delete(':id')
async delete(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
) {
const userId = extractUserId(authHeader);
await this.listService.delete(userId, id);
return { success: true };
}
@Delete(':id')
async delete(@Headers('authorization') authHeader: string, @Param('id') id: string) {
const userId = extractUserId(authHeader);
await this.listService.delete(userId, id);
return { success: true };
}
@Post(':id/quotes')
async addQuote(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
@Body() dto: AddQuoteDto,
) {
const userId = extractUserId(authHeader);
const list = await this.listService.addQuoteToList(userId, id, dto.quoteId);
return { list };
}
@Post(':id/quotes')
async addQuote(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
@Body() dto: AddQuoteDto
) {
const userId = extractUserId(authHeader);
const list = await this.listService.addQuoteToList(userId, id, dto.quoteId);
return { list };
}
@Delete(':id/quotes/:quoteId')
async removeQuote(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
@Param('quoteId') quoteId: string,
) {
const userId = extractUserId(authHeader);
const list = await this.listService.removeQuoteFromList(userId, id, quoteId);
return { list };
}
@Delete(':id/quotes/:quoteId')
async removeQuote(
@Headers('authorization') authHeader: string,
@Param('id') id: string,
@Param('quoteId') quoteId: string
) {
const userId = extractUserId(authHeader);
const list = await this.listService.removeQuoteFromList(userId, id, quoteId);
return { list };
}
}

View file

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

View file

@ -6,70 +6,70 @@ import { userLists, type UserList, type NewUserList } from '../db/schema';
@Injectable()
export class ListService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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 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)));
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;
}
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 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();
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;
}
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)));
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');
}
}
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 || [];
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);
}
if (!quoteIds.includes(quoteId)) {
quoteIds.push(quoteId);
}
return this.update(userId, listId, { quoteIds });
}
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 });
}
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

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

View file

@ -1,25 +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"]
"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

@ -1,19 +1,19 @@
{
"name": "@quote/landing",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.3.4"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.2",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
}
"name": "@quote/landing",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.3.4"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.2",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
}
}

View file

@ -1,7 +1,7 @@
---
interface Props {
title: string;
description?: string;
title: string;
description?: string;
}
const { title, description = 'Discover inspiring quotes from great minds' } = Astro.props;
@ -9,64 +9,67 @@ const { title, description = 'Discover inspiring quotes from great minds' } = As
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<nav class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<a href="/" class="text-2xl font-bold">📖 Quotes</a>
<div class="flex gap-6">
<a href="/#features" class="hover:opacity-80">Features</a>
<a href="/#about" class="hover:opacity-80">About</a>
<a href="/app" class="bg-white text-purple-600 px-4 py-2 rounded-lg font-semibold hover:opacity-90">
Open App
</a>
</div>
</div>
</div>
</nav>
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<nav class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<a href="/" class="text-2xl font-bold">📖 Quotes</a>
<div class="flex gap-6">
<a href="/#features" class="hover:opacity-80">Features</a>
<a href="/#about" class="hover:opacity-80">About</a>
<a
href="/app"
class="bg-white text-purple-600 px-4 py-2 rounded-lg font-semibold hover:opacity-90"
>
Open App
</a>
</div>
</div>
</div>
</nav>
<slot />
<slot />
<footer class="bg-gray-900 text-white py-12">
<div class="container mx-auto px-6 text-center">
<p>&copy; 2025 Quotes App. All rights reserved.</p>
<div class="mt-4 flex justify-center gap-6">
<a href="/privacy" class="hover:opacity-80">Privacy</a>
<a href="/terms" class="hover:opacity-80">Terms</a>
<a href="/contact" class="hover:opacity-80">Contact</a>
</div>
</div>
</footer>
</body>
<footer class="bg-gray-900 text-white py-12">
<div class="container mx-auto px-6 text-center">
<p>&copy; 2025 Quotes App. All rights reserved.</p>
<div class="mt-4 flex justify-center gap-6">
<a href="/privacy" class="hover:opacity-80">Privacy</a>
<a href="/terms" class="hover:opacity-80">Terms</a>
<a href="/contact" class="hover:opacity-80">Contact</a>
</div>
</div>
</footer>
</body>
</html>
<style is:global>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: system-ui, sans-serif;
background: #ffffff;
}
html {
font-family: system-ui, sans-serif;
background: #ffffff;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
main {
flex: 1;
}
</style>

View file

@ -3,118 +3,120 @@ import Layout from '../layouts/Layout.astro';
---
<Layout title="Quotes - Inspiring Words from Great Minds">
<main>
<!-- Hero Section -->
<section class="bg-gradient-to-br from-purple-50 to-indigo-100 py-20">
<div class="container mx-auto px-6 text-center">
<h1 class="text-5xl md:text-6xl font-bold text-gray-900 mb-6">
Discover Inspiring Quotes
</h1>
<p class="text-xl md:text-2xl text-gray-700 mb-8 max-w-2xl mx-auto">
Explore wisdom from history's greatest thinkers. Available on mobile, web, and everywhere you need inspiration.
</p>
<div class="flex gap-4 justify-center flex-wrap">
<a href="/app" class="bg-purple-600 text-white px-8 py-4 rounded-lg text-lg font-semibold hover:bg-purple-700 transition">
Open Web App
</a>
<a href="#features" class="bg-white text-purple-600 px-8 py-4 rounded-lg text-lg font-semibold hover:bg-gray-50 transition border-2 border-purple-600">
Learn More
</a>
</div>
</div>
</section>
<main>
<!-- Hero Section -->
<section class="bg-gradient-to-br from-purple-50 to-indigo-100 py-20">
<div class="container mx-auto px-6 text-center">
<h1 class="text-5xl md:text-6xl font-bold text-gray-900 mb-6">Discover Inspiring Quotes</h1>
<p class="text-xl md:text-2xl text-gray-700 mb-8 max-w-2xl mx-auto">
Explore wisdom from history's greatest thinkers. Available on mobile, web, and everywhere
you need inspiration.
</p>
<div class="flex gap-4 justify-center flex-wrap">
<a
href="/app"
class="bg-purple-600 text-white px-8 py-4 rounded-lg text-lg font-semibold hover:bg-purple-700 transition"
>
Open Web App
</a>
<a
href="#features"
class="bg-white text-purple-600 px-8 py-4 rounded-lg text-lg font-semibold hover:bg-gray-50 transition border-2 border-purple-600"
>
Learn More
</a>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-20">
<div class="container mx-auto px-6">
<h2 class="text-4xl font-bold text-center mb-12 text-gray-900">Features</h2>
<!-- Features Section -->
<section id="features" class="py-20">
<div class="container mx-auto px-6">
<h2 class="text-4xl font-bold text-center mb-12 text-gray-900">Features</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="text-center p-6">
<div class="text-5xl mb-4">📱</div>
<h3 class="text-xl font-bold mb-2">Mobile App</h3>
<p class="text-gray-600">
Native iOS and Android app with offline support and beautiful design.
</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div class="text-center p-6">
<div class="text-5xl mb-4">📱</div>
<h3 class="text-xl font-bold mb-2">Mobile App</h3>
<p class="text-gray-600">
Native iOS and Android app with offline support and beautiful design.
</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">🌐</div>
<h3 class="text-xl font-bold mb-2">Web Access</h3>
<p class="text-gray-600">
Access your favorite quotes from any browser, anywhere.
</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">🌐</div>
<h3 class="text-xl font-bold mb-2">Web Access</h3>
<p class="text-gray-600">Access your favorite quotes from any browser, anywhere.</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">📚</div>
<h3 class="text-xl font-bold mb-2">1000+ Quotes</h3>
<p class="text-gray-600">
Curated collection from philosophers, scientists, and leaders.
</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">📚</div>
<h3 class="text-xl font-bold mb-2">1000+ Quotes</h3>
<p class="text-gray-600">
Curated collection from philosophers, scientists, and leaders.
</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">⭐</div>
<h3 class="text-xl font-bold mb-2">Favorites</h3>
<p class="text-gray-600">
Save your favorite quotes and access them instantly.
</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">⭐</div>
<h3 class="text-xl font-bold mb-2">Favorites</h3>
<p class="text-gray-600">Save your favorite quotes and access them instantly.</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">🔍</div>
<h3 class="text-xl font-bold mb-2">Search & Filter</h3>
<p class="text-gray-600">
Find quotes by author, category, or keyword.
</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">🔍</div>
<h3 class="text-xl font-bold mb-2">Search & Filter</h3>
<p class="text-gray-600">Find quotes by author, category, or keyword.</p>
</div>
<div class="text-center p-6">
<div class="text-5xl mb-4">🎨</div>
<h3 class="text-xl font-bold mb-2">Beautiful Design</h3>
<p class="text-gray-600">
Elegant interface that puts content first.
</p>
</div>
</div>
</div>
</section>
<div class="text-center p-6">
<div class="text-5xl mb-4">🎨</div>
<h3 class="text-xl font-bold mb-2">Beautiful Design</h3>
<p class="text-gray-600">Elegant interface that puts content first.</p>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section id="about" class="bg-gray-50 py-20">
<div class="container mx-auto px-6">
<div class="max-w-3xl mx-auto text-center">
<h2 class="text-4xl font-bold mb-6 text-gray-900">About Quotes</h2>
<p class="text-xl text-gray-700 mb-6">
Quotes is your daily source of inspiration and wisdom. We've carefully curated
over 1000 quotes from history's most influential thinkers, philosophers, scientists,
and leaders.
</p>
<p class="text-lg text-gray-600">
Whether you're looking for motivation, wisdom, or just a moment of reflection,
Quotes brings you timeless words that inspire and enlighten.
</p>
</div>
</div>
</section>
<!-- About Section -->
<section id="about" class="bg-gray-50 py-20">
<div class="container mx-auto px-6">
<div class="max-w-3xl mx-auto text-center">
<h2 class="text-4xl font-bold mb-6 text-gray-900">About Quotes</h2>
<p class="text-xl text-gray-700 mb-6">
Quotes is your daily source of inspiration and wisdom. We've carefully curated over 1000
quotes from history's most influential thinkers, philosophers, scientists, and leaders.
</p>
<p class="text-lg text-gray-600">
Whether you're looking for motivation, wisdom, or just a moment of reflection, Quotes
brings you timeless words that inspire and enlighten.
</p>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-20">
<div class="container mx-auto px-6 text-center">
<h2 class="text-4xl font-bold mb-6">Ready to Get Inspired?</h2>
<p class="text-xl mb-8 max-w-2xl mx-auto">
Start exploring thousands of inspiring quotes today.
</p>
<div class="flex gap-4 justify-center flex-wrap">
<a href="/app" class="bg-white text-purple-600 px-8 py-4 rounded-lg text-lg font-semibold hover:bg-gray-100 transition">
Launch Web App
</a>
<a href="#" class="bg-purple-800 text-white px-8 py-4 rounded-lg text-lg font-semibold hover:bg-purple-900 transition">
Download Mobile App
</a>
</div>
</div>
</section>
</main>
<!-- CTA Section -->
<section class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white py-20">
<div class="container mx-auto px-6 text-center">
<h2 class="text-4xl font-bold mb-6">Ready to Get Inspired?</h2>
<p class="text-xl mb-8 max-w-2xl mx-auto">
Start exploring thousands of inspiring quotes today.
</p>
<div class="flex gap-4 justify-center flex-wrap">
<a
href="/app"
class="bg-white text-purple-600 px-8 py-4 rounded-lg text-lg font-semibold hover:bg-gray-100 transition"
>
Launch Web App
</a>
<a
href="#"
class="bg-purple-800 text-white px-8 py-4 rounded-lg text-lg font-semibold hover:bg-purple-900 transition"
>
Download Mobile App
</a>
</div>
</div>
</section>
</main>
</Layout>

View file

@ -1,7 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

View file

@ -1,69 +1,65 @@
{
"expo": {
"name": "Zitare",
"slug": "quote",
"version": "1.0.0",
"scheme": "zitare",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-web-browser",
"expo-font",
"@bacons/apple-targets",
[
"expo-document-picker",
{
"iCloudContainerEnvironment": "Production"
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.memoro.zitare",
"icon": "./assets/zitare.icon",
"usesIcloudStorage": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSDocumentsFolderUsageDescription": "Diese App benötigt Zugriff auf den Dokumentenordner, um Backups zu speichern und wiederherzustellen.",
"UISupportsDocumentBrowser": true
},
"entitlements": {
"com.apple.security.application-groups": [
"group.com.memoro.zitare.widget"
]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.memoro.zitare"
},
"extra": {
"router": {},
"eas": {
"projectId": "8b630a7d-8b83-4847-81b0-e7922bfba178"
}
}
}
"expo": {
"name": "Zitare",
"slug": "quote",
"version": "1.0.0",
"scheme": "zitare",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-web-browser",
"expo-font",
"@bacons/apple-targets",
[
"expo-document-picker",
{
"iCloudContainerEnvironment": "Production"
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.memoro.zitare",
"icon": "./assets/zitare.icon",
"usesIcloudStorage": true,
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSDocumentsFolderUsageDescription": "Diese App benötigt Zugriff auf den Dokumentenordner, um Backups zu speichern und wiederherzustellen.",
"UISupportsDocumentBrowser": true
},
"entitlements": {
"com.apple.security.application-groups": ["group.com.memoro.zitare.widget"]
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.memoro.zitare"
},
"extra": {
"router": {},
"eas": {
"projectId": "8b630a7d-8b83-4847-81b0-e7922bfba178"
}
}
}
}

View file

@ -8,109 +8,98 @@ import { DynamicColorIOS, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
export default function NativeTabLayout() {
const { t } = useTranslation();
// Monochrome Farben für Liquid Glass - passen sich automatisch an hell/dunkel an
const liquidGlassColors = Platform.OS === 'ios' ? {
// Text Farbe
color: DynamicColorIOS({
dark: 'white',
light: 'black',
}),
// Icon Farbe (selected)
tintColor: DynamicColorIOS({
dark: 'white',
light: 'black',
}),
} : {
color: '#ffffff',
tintColor: '#ffffff',
};
const { t } = useTranslation();
return (
<NativeTabs
// Farben für Liquid Glass
labelStyle={liquidGlassColors}
>
{/* Zitate Tab */}
<NativeTabs.Trigger
name="quotes"
// Optional: Scroll-to-top beim erneuten Tippen
disableScrollToTop={false}
>
<Label>{t('navigation.quotes')}</Label>
<Icon
// SF Symbols für iOS (beste native Integration)
sf={{
default: "book",
selected: "book.fill"
}}
// Fallback für Android
drawable="ic_book"
/>
</NativeTabs.Trigger>
// Monochrome Farben für Liquid Glass - passen sich automatisch an hell/dunkel an
const liquidGlassColors =
Platform.OS === 'ios'
? {
// Text Farbe
color: DynamicColorIOS({
dark: 'white',
light: 'black',
}),
// Icon Farbe (selected)
tintColor: DynamicColorIOS({
dark: 'white',
light: 'black',
}),
}
: {
color: '#ffffff',
tintColor: '#ffffff',
};
{/* Autoren Tab */}
<NativeTabs.Trigger
name="authors"
disableScrollToTop={false}
>
<Label>{t('navigation.authors')}</Label>
<Icon
sf={{
default: "person.2",
selected: "person.2.fill"
}}
drawable="ic_people"
/>
</NativeTabs.Trigger>
return (
<NativeTabs
// Farben für Liquid Glass
labelStyle={liquidGlassColors}
>
{/* Zitate Tab */}
<NativeTabs.Trigger
name="quotes"
// Optional: Scroll-to-top beim erneuten Tippen
disableScrollToTop={false}
>
<Label>{t('navigation.quotes')}</Label>
<Icon
// SF Symbols für iOS (beste native Integration)
sf={{
default: 'book',
selected: 'book.fill',
}}
// Fallback für Android
drawable="ic_book"
/>
</NativeTabs.Trigger>
{/* Listen Tab */}
<NativeTabs.Trigger
name="liste"
disableScrollToTop={false}
>
<Label>Listen</Label>
<Icon
sf={{
default: "list.bullet",
selected: "list.bullet.rectangle.fill"
}}
drawable="ic_list"
/>
</NativeTabs.Trigger>
{/* Autoren Tab */}
<NativeTabs.Trigger name="authors" disableScrollToTop={false}>
<Label>{t('navigation.authors')}</Label>
<Icon
sf={{
default: 'person.2',
selected: 'person.2.fill',
}}
drawable="ic_people"
/>
</NativeTabs.Trigger>
{/* Meine Zitate Tab */}
<NativeTabs.Trigger
name="myquotes"
disableScrollToTop={false}
>
<Label>{t('navigation.myQuotes')}</Label>
<Icon
sf={{
default: "square.and.pencil",
selected: "square.and.pencil.fill"
}}
drawable="ic_create"
/>
</NativeTabs.Trigger>
{/* Listen Tab */}
<NativeTabs.Trigger name="liste" disableScrollToTop={false}>
<Label>Listen</Label>
<Icon
sf={{
default: 'list.bullet',
selected: 'list.bullet.rectangle.fill',
}}
drawable="ic_list"
/>
</NativeTabs.Trigger>
{/* Search Tab - iOS 18+: Separate search tab für native Suche */}
<NativeTabs.Trigger
name="search"
role="search"
disableScrollToTop={false}
>
<Label>{t('navigation.search')}</Label>
<Icon
sf={{
default: "magnifyingglass",
selected: "magnifyingglass.circle.fill"
}}
drawable="ic_search"
/>
</NativeTabs.Trigger>
{/* Meine Zitate Tab */}
<NativeTabs.Trigger name="myquotes" disableScrollToTop={false}>
<Label>{t('navigation.myQuotes')}</Label>
<Icon
sf={{
default: 'square.and.pencil',
selected: 'square.and.pencil.fill',
}}
drawable="ic_create"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}
{/* Search Tab - iOS 18+: Separate search tab für native Suche */}
<NativeTabs.Trigger name="search" role="search" disableScrollToTop={false}>
<Label>{t('navigation.search')}</Label>
<Icon
sf={{
default: 'magnifyingglass',
selected: 'magnifyingglass.circle.fill',
}}
drawable="ic_search"
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}

View file

@ -15,175 +15,158 @@ let NativeTabLayout: any = null;
const ENABLE_NATIVE_TABS = true; // Setze auf false zum Deaktivieren
if (ENABLE_NATIVE_TABS && Platform.OS === 'ios') {
try {
// Prüfe ob Native Tabs verfügbar sind
const nativeTabsModule = require('expo-router/unstable-native-tabs');
if (nativeTabsModule && nativeTabsModule.NativeTabs) {
// Lade unsere Native Tabs Implementation
NativeTabLayout = require('./NativeTabsLayout').default;
}
} catch (error) {
}
try {
// Prüfe ob Native Tabs verfügbar sind
const nativeTabsModule = require('expo-router/unstable-native-tabs');
if (nativeTabsModule && nativeTabsModule.NativeTabs) {
// Lade unsere Native Tabs Implementation
NativeTabLayout = require('./NativeTabsLayout').default;
}
} catch (error) {}
}
export default function TabLayout() {
const { t } = useTranslation();
// Wenn Native Tabs verfügbar sind, nutze sie
if (NativeTabLayout) {
return <NativeTabLayout />;
}
const { t } = useTranslation();
// Fallback: Enhanced Blur Tabs für ältere iOS Versionen
const isIOS = Platform.OS === 'ios';
return (
<Tabs
initialRouteName="quotes"
screenOptions={{
tabBarActiveTintColor: '#ffffff',
tabBarInactiveTintColor: '#6b7280',
tabBarStyle: isIOS ? {
// iOS: Transparenter Hintergrund für Blur-Effekt
position: 'absolute',
backgroundColor: 'transparent',
borderTopWidth: 0,
elevation: 0,
height: 95,
paddingTop: 10,
paddingBottom: 35,
} : {
// Android: Standard Style
backgroundColor: '#0f0f0f',
borderTopColor: '#1a1a1a',
height: 90,
paddingTop: 10,
paddingBottom: 30,
},
tabBarBackground: () => isIOS ? (
<BlurView
intensity={100}
tint="dark"
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
) : null,
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '600',
},
}}>
<Tabs.Screen
name="quotes"
options={{
title: t('navigation.quotes'),
tabBarLabel: t('navigation.quotes'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons
name={focused ? "book" : "book-outline"}
size={28}
color={color}
/>
</View>
),
}}
/>
<Tabs.Screen
name="authors"
options={{
title: t('navigation.authors'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons
name={focused ? "people" : "people-outline"}
size={28}
color={color}
/>
</View>
),
}}
/>
<Tabs.Screen
name="liste"
options={{
title: t('lists.lists'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons
name={focused ? "list" : "list-outline"}
size={28}
color={color}
/>
</View>
),
}}
/>
<Tabs.Screen
name="myquotes"
options={{
title: t('navigation.myQuotes'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons
name={focused ? "create" : "create-outline"}
size={28}
color={color}
/>
</View>
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: t('navigation.search'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons
name={focused ? "search" : "search-outline"}
size={28}
color={color}
/>
</View>
),
}}
/>
</Tabs>
);
}
// Wenn Native Tabs verfügbar sind, nutze sie
if (NativeTabLayout) {
return <NativeTabLayout />;
}
// Fallback: Enhanced Blur Tabs für ältere iOS Versionen
const isIOS = Platform.OS === 'ios';
return (
<Tabs
initialRouteName="quotes"
screenOptions={{
tabBarActiveTintColor: '#ffffff',
tabBarInactiveTintColor: '#6b7280',
tabBarStyle: isIOS
? {
// iOS: Transparenter Hintergrund für Blur-Effekt
position: 'absolute',
backgroundColor: 'transparent',
borderTopWidth: 0,
elevation: 0,
height: 95,
paddingTop: 10,
paddingBottom: 35,
}
: {
// Android: Standard Style
backgroundColor: '#0f0f0f',
borderTopColor: '#1a1a1a',
height: 90,
paddingTop: 10,
paddingBottom: 30,
},
tabBarBackground: () =>
isIOS ? (
<BlurView
intensity={100}
tint="dark"
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
) : null,
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '600',
},
}}
>
<Tabs.Screen
name="quotes"
options={{
title: t('navigation.quotes'),
tabBarLabel: t('navigation.quotes'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons name={focused ? 'book' : 'book-outline'} size={28} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="authors"
options={{
title: t('navigation.authors'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons name={focused ? 'people' : 'people-outline'} size={28} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="liste"
options={{
title: t('lists.lists'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons name={focused ? 'list' : 'list-outline'} size={28} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="myquotes"
options={{
title: t('navigation.myQuotes'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons name={focused ? 'create' : 'create-outline'} size={28} color={color} />
</View>
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: t('navigation.search'),
tabBarIcon: ({ color, focused }) => (
<View>
{focused && (
<View
className="absolute -inset-2 rounded-full bg-white/10"
style={{ width: 48, height: 48 }}
/>
)}
<Ionicons name={focused ? 'search' : 'search-outline'} size={28} color={color} />
</View>
),
}}
/>
</Tabs>
);
}

View file

@ -1,14 +1,14 @@
import { Stack } from 'expo-router';
export default function AuthorsLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
import { Stack } from 'expo-router';
export default function ListeLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
import { Stack } from 'expo-router';
export default function MyQuotesLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}

View file

@ -10,482 +10,505 @@ import { BlurView } from 'expo-blur';
import { GlassFAB } from '~/components/common/GlassFAB';
// DateTimePicker, Host, Button removed - no longer needed for inline editing
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
runOnJS
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
import { LIST_CONTAINER_PADDING, LIST_ITEM_CLASSES } from '~/constants/layout';
import { useTranslation } from 'react-i18next';
import { useTheme } from '~/hooks/useTheme';
export default function MyQuotes() {
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const { userName } = useSettingsStore();
const { t } = useTranslation();
const {
getUserQuotes,
addUserQuote,
updateUserQuote,
deleteUserQuote,
toggleUserQuoteFavorite,
initializeStore
} = useQuotesStore();
// Note: Modal-based editing removed in favor of inline editing
// View mode state
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
// Inline editing states
const [isCreatingNew, setIsCreatingNew] = useState(false);
const [newQuoteData, setNewQuoteData] = useState({
text: '',
author: userName || '',
categories: '',
});
// Inline edit states for existing quotes
const [editingQuoteId, setEditingQuoteId] = useState<string | null>(null);
const [editQuoteData, setEditQuoteData] = useState({
text: '',
author: '',
categories: '',
});
// showDatePicker removed - no longer needed for inline editing
const fabScale = useSharedValue(1);
const newQuoteScale = useSharedValue(0.98);
const newQuoteOpacity = useSharedValue(0);
const editQuoteScale = useSharedValue(1);
const editQuoteOpacity = useSharedValue(1);
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const { userName } = useSettingsStore();
const { t } = useTranslation();
const {
getUserQuotes,
addUserQuote,
updateUserQuote,
deleteUserQuote,
toggleUserQuoteFavorite,
initializeStore,
} = useQuotesStore();
useEffect(() => {
initializeStore();
}, []);
// Note: Modal-based editing removed in favor of inline editing
const userQuotes = getUserQuotes();
// Convert UserQuote to EnhancedQuote format for QuoteCard
const convertUserQuoteToEnhanced = React.useCallback((userQuote: UserQuote): EnhancedQuote => ({
id: userQuote.id,
text: userQuote.text,
author: {
id: `user-author-${userQuote.id}`,
name: userQuote.author,
profession: []
},
authorId: `user-author-${userQuote.id}`,
categories: userQuote.categories || [],
tags: userQuote.categories || [],
isFavorite: userQuote.isFavorite || false,
source: userQuote.quoteDate ? new Date(userQuote.quoteDate).toLocaleDateString('de-DE') : undefined,
year: userQuote.quoteDate ? new Date(userQuote.quoteDate).getFullYear().toString() : undefined,
category: userQuote.categories?.[0] || 'personal'
}), []);
// Create empty quote for inline creation
const createEmptyQuote = React.useCallback((): EnhancedQuote => ({
id: 'new-quote-temp',
text: newQuoteData.text,
author: {
id: 'new-author-temp',
name: newQuoteData.author,
profession: []
},
authorId: 'new-author-temp',
categories: newQuoteData.categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
tags: [],
isFavorite: false,
source: undefined,
year: undefined,
category: 'personal'
}), [newQuoteData]);
// Create display data including potential new quote at top
const displayQuotes = React.useMemo(() => {
if (isCreatingNew) {
const emptyQuote = createEmptyQuote();
return [emptyQuote, ...userQuotes.map(convertUserQuoteToEnhanced)];
}
return userQuotes.map(convertUserQuoteToEnhanced);
}, [isCreatingNew, userQuotes, createEmptyQuote, convertUserQuoteToEnhanced]);
// View mode state
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
// Note: Modal-based editor functions removed in favor of inline editing
// Inline editing states
const [isCreatingNew, setIsCreatingNew] = useState(false);
const [newQuoteData, setNewQuoteData] = useState({
text: '',
author: userName || '',
categories: '',
});
const handleDelete = (id: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
deleteUserQuote(id);
};
// Inline editing functions
const startInlineCreation = () => {
setNewQuoteData({
text: '',
author: userName || '',
categories: '',
});
setIsCreatingNew(true);
// Animate new quote appearance - very subtle
newQuoteScale.value = withSpring(1, { damping: 25, stiffness: 300, mass: 0.8 });
newQuoteOpacity.value = withTiming(1, { duration: 100 });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleInlineTextChange = (text: string) => {
setNewQuoteData(prev => ({ ...prev, text }));
};
const handleInlineAuthorChange = (author: string) => {
setNewQuoteData(prev => ({ ...prev, author }));
};
const handleInlineCategoryChange = (categories: string) => {
setNewQuoteData(prev => ({ ...prev, categories }));
};
const saveInlineQuote = () => {
if (!newQuoteData.text.trim()) return;
const categories = newQuoteData.categories
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0);
const finalAuthor = newQuoteData.author || userName || 'Ich';
addUserQuote({
text: newQuoteData.text,
author: finalAuthor,
categories,
quoteDate: new Date().toISOString()
});
// Animate out and reset
newQuoteScale.value = withSpring(0.8, {}, () => {
newQuoteScale.value = withSpring(1);
});
setIsCreatingNew(false);
setNewQuoteData({
text: '',
author: userName || '',
categories: '',
});
// Reset animation values
newQuoteScale.value = 0.98;
newQuoteOpacity.value = 0;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
// Helper function to reset state safely
const resetCreationState = () => {
setIsCreatingNew(false);
setNewQuoteData({
text: '',
author: userName || '',
categories: '',
});
// Reset animation values
newQuoteScale.value = 0.98;
newQuoteOpacity.value = 0;
};
// Inline edit states for existing quotes
const [editingQuoteId, setEditingQuoteId] = useState<string | null>(null);
const [editQuoteData, setEditQuoteData] = useState({
text: '',
author: '',
categories: '',
});
// showDatePicker removed - no longer needed for inline editing
const cancelInlineCreation = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Animate out before removing - subtle and fast
newQuoteScale.value = withTiming(0.95, { duration: 100 });
newQuoteOpacity.value = withTiming(0, { duration: 150 }, (finished) => {
// Only reset if animation completed successfully
if (finished) {
runOnJS(resetCreationState)();
}
});
};
// Inline edit functions for existing quotes
const startInlineEdit = (quote: UserQuote) => {
setEditingQuoteId(quote.id);
setEditQuoteData({
text: quote.text,
author: quote.author,
categories: quote.categories?.join(', ') || '',
});
// Subtle animation feedback
editQuoteScale.value = withSpring(0.98, { damping: 25, stiffness: 300, mass: 0.8 });
editQuoteOpacity.value = withTiming(0.95, { duration: 100 });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleEditTextChange = (text: string) => {
setEditQuoteData(prev => ({ ...prev, text }));
};
const handleEditAuthorChange = (author: string) => {
setEditQuoteData(prev => ({ ...prev, author }));
};
const handleEditCategoryChange = (categories: string) => {
setEditQuoteData(prev => ({ ...prev, categories }));
};
const saveInlineEdit = () => {
if (!editQuoteData.text.trim() || !editingQuoteId) return;
const categories = editQuoteData.categories
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0);
const finalAuthor = editQuoteData.author || userName || 'Ich';
updateUserQuote(editingQuoteId, {
text: editQuoteData.text,
author: finalAuthor,
categories,
quoteDate: userQuotes.find(q => q.id === editingQuoteId)?.quoteDate || new Date().toISOString()
});
// Animate completion
editQuoteScale.value = withSpring(1.02, {}, () => {
editQuoteScale.value = withSpring(1);
});
editQuoteOpacity.value = withTiming(1, { duration: 150 });
setEditingQuoteId(null);
setEditQuoteData({
text: '',
author: '',
categories: '',
});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const cancelInlineEdit = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Animate back to normal
editQuoteScale.value = withSpring(1, { damping: 25, stiffness: 300 });
editQuoteOpacity.value = withTiming(1, { duration: 150 });
setEditingQuoteId(null);
setEditQuoteData({
text: '',
author: '',
categories: '',
});
};
const fabScale = useSharedValue(1);
const newQuoteScale = useSharedValue(0.98);
const newQuoteOpacity = useSharedValue(0);
const editQuoteScale = useSharedValue(1);
const editQuoteOpacity = useSharedValue(1);
// fabAnimatedStyle removed - not needed for current FAB implementation
useEffect(() => {
initializeStore();
}, []);
const newQuoteAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: newQuoteScale.value }],
opacity: newQuoteOpacity.value,
}));
const editQuoteAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: editQuoteScale.value }],
opacity: editQuoteOpacity.value,
}));
const userQuotes = getUserQuotes();
const renderQuote = ({ item, index }: { item: EnhancedQuote; index: number }) => {
const isNewQuote = item.id === 'new-quote-temp';
const isBeingEdited = editingQuoteId === item.id;
if (isNewQuote) {
return (
<Animated.View style={newQuoteAnimatedStyle} className="mb-5">
<QuoteCard
quote={item}
variant="edit"
editMode={true}
onToggleFavorite={() => {}}
onTextChange={handleInlineTextChange}
onAuthorChange={handleInlineAuthorChange}
onCategoryChange={handleInlineCategoryChange}
onSave={saveInlineQuote}
onCancel={cancelInlineCreation}
/>
</Animated.View>
);
}
// Find original UserQuote for existing quotes
const originalQuote = userQuotes.find(q => q.id === item.id);
if (!originalQuote) return null;
// If this quote is being edited inline, show edit mode
if (isBeingEdited) {
const editQuote: EnhancedQuote = {
...item,
text: editQuoteData.text,
author: {
...item.author,
name: editQuoteData.author
},
categories: editQuoteData.categories.split(',').map(c => c.trim()).filter(c => c.length > 0)
};
return (
<Animated.View style={editQuoteAnimatedStyle} className="mb-5">
<QuoteCard
quote={editQuote}
variant="edit"
editMode={true}
onToggleFavorite={() => {}}
onTextChange={handleEditTextChange}
onAuthorChange={handleEditAuthorChange}
onCategoryChange={handleEditCategoryChange}
onSave={saveInlineEdit}
onCancel={cancelInlineEdit}
/>
</Animated.View>
);
}
// Use different variant based on viewMode
if (viewMode === 'card') {
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
cardHeight={600} // Adjust height as needed
onToggleFavorite={() => toggleUserQuoteFavorite(originalQuote.id)}
onAuthorPress={() => {}}
onEdit={() => startInlineEdit(originalQuote)}
onDelete={() => handleDelete(originalQuote.id)}
/>
</View>
);
}
// List view (default)
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={() => toggleUserQuoteFavorite(originalQuote.id)}
onAuthorPress={() => {}} // Remove author press to avoid modal
onEdit={() => startInlineEdit(originalQuote)}
onDelete={() => handleDelete(originalQuote.id)}
/>
</View>
);
};
// Convert UserQuote to EnhancedQuote format for QuoteCard
const convertUserQuoteToEnhanced = React.useCallback(
(userQuote: UserQuote): EnhancedQuote => ({
id: userQuote.id,
text: userQuote.text,
author: {
id: `user-author-${userQuote.id}`,
name: userQuote.author,
profession: [],
},
authorId: `user-author-${userQuote.id}`,
categories: userQuote.categories || [],
tags: userQuote.categories || [],
isFavorite: userQuote.isFavorite || false,
source: userQuote.quoteDate
? new Date(userQuote.quoteDate).toLocaleDateString('de-DE')
: undefined,
year: userQuote.quoteDate
? new Date(userQuote.quoteDate).getFullYear().toString()
: undefined,
category: userQuote.categories?.[0] || 'personal',
}),
[]
);
return (
<>
<Stack.Screen
options={{
title: t('myQuotes.title'),
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'list' ? 'grid-outline' : 'list-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push('/settings')}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name="settings-outline"
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
</View>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
// Create empty quote for inline creation
const createEmptyQuote = React.useCallback(
(): EnhancedQuote => ({
id: 'new-quote-temp',
text: newQuoteData.text,
author: {
id: 'new-author-temp',
name: newQuoteData.author,
profession: [],
},
authorId: 'new-author-temp',
categories: newQuoteData.categories
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0),
tags: [],
isFavorite: false,
source: undefined,
year: undefined,
category: 'personal',
}),
[newQuoteData]
);
{userQuotes.length === 0 && !isCreatingNew ? (
<View className="flex-1 justify-center items-center px-6" style={{ paddingTop: 100 }}>
<Icon
name="create-outline"
size={64}
color={isDarkMode ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)"}
/>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-lg mt-4 text-center font-semibold`}>
{t('myQuotes.noQuotes')}
</Text>
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm mt-2 text-center`}>
{t('myQuotes.createFirst')}
</Text>
</View>
) : (
<FlatList
data={displayQuotes}
renderItem={renderQuote}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: LIST_CONTAINER_PADDING.bottom, paddingTop: LIST_CONTAINER_PADDING.top }}
pagingEnabled={viewMode === 'card'}
snapToInterval={viewMode === 'card' ? 600 : undefined}
snapToAlignment={viewMode === 'card' ? "start" : undefined}
decelerationRate={viewMode === 'card' ? "fast" : "normal"}
/>
)}
// Create display data including potential new quote at top
const displayQuotes = React.useMemo(() => {
if (isCreatingNew) {
const emptyQuote = createEmptyQuote();
return [emptyQuote, ...userQuotes.map(convertUserQuoteToEnhanced)];
}
return userQuotes.map(convertUserQuoteToEnhanced);
}, [isCreatingNew, userQuotes, createEmptyQuote, convertUserQuoteToEnhanced]);
{/* Floating Action Button */}
{!isCreatingNew && (
<GlassFAB
onPress={startInlineCreation}
icon="add"
size="medium"
position="bottom-right"
/>
)}
// Note: Modal-based editor functions removed in favor of inline editing
{/* Modal-based editing removed - now using inline editing */}
</View>
</>
);
}
const handleDelete = (id: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
deleteUserQuote(id);
};
// Inline editing functions
const startInlineCreation = () => {
setNewQuoteData({
text: '',
author: userName || '',
categories: '',
});
setIsCreatingNew(true);
// Animate new quote appearance - very subtle
newQuoteScale.value = withSpring(1, { damping: 25, stiffness: 300, mass: 0.8 });
newQuoteOpacity.value = withTiming(1, { duration: 100 });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleInlineTextChange = (text: string) => {
setNewQuoteData((prev) => ({ ...prev, text }));
};
const handleInlineAuthorChange = (author: string) => {
setNewQuoteData((prev) => ({ ...prev, author }));
};
const handleInlineCategoryChange = (categories: string) => {
setNewQuoteData((prev) => ({ ...prev, categories }));
};
const saveInlineQuote = () => {
if (!newQuoteData.text.trim()) return;
const categories = newQuoteData.categories
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0);
const finalAuthor = newQuoteData.author || userName || 'Ich';
addUserQuote({
text: newQuoteData.text,
author: finalAuthor,
categories,
quoteDate: new Date().toISOString(),
});
// Animate out and reset
newQuoteScale.value = withSpring(0.8, {}, () => {
newQuoteScale.value = withSpring(1);
});
setIsCreatingNew(false);
setNewQuoteData({
text: '',
author: userName || '',
categories: '',
});
// Reset animation values
newQuoteScale.value = 0.98;
newQuoteOpacity.value = 0;
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
// Helper function to reset state safely
const resetCreationState = () => {
setIsCreatingNew(false);
setNewQuoteData({
text: '',
author: userName || '',
categories: '',
});
// Reset animation values
newQuoteScale.value = 0.98;
newQuoteOpacity.value = 0;
};
const cancelInlineCreation = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Animate out before removing - subtle and fast
newQuoteScale.value = withTiming(0.95, { duration: 100 });
newQuoteOpacity.value = withTiming(0, { duration: 150 }, (finished) => {
// Only reset if animation completed successfully
if (finished) {
runOnJS(resetCreationState)();
}
});
};
// Inline edit functions for existing quotes
const startInlineEdit = (quote: UserQuote) => {
setEditingQuoteId(quote.id);
setEditQuoteData({
text: quote.text,
author: quote.author,
categories: quote.categories?.join(', ') || '',
});
// Subtle animation feedback
editQuoteScale.value = withSpring(0.98, { damping: 25, stiffness: 300, mass: 0.8 });
editQuoteOpacity.value = withTiming(0.95, { duration: 100 });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleEditTextChange = (text: string) => {
setEditQuoteData((prev) => ({ ...prev, text }));
};
const handleEditAuthorChange = (author: string) => {
setEditQuoteData((prev) => ({ ...prev, author }));
};
const handleEditCategoryChange = (categories: string) => {
setEditQuoteData((prev) => ({ ...prev, categories }));
};
const saveInlineEdit = () => {
if (!editQuoteData.text.trim() || !editingQuoteId) return;
const categories = editQuoteData.categories
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0);
const finalAuthor = editQuoteData.author || userName || 'Ich';
updateUserQuote(editingQuoteId, {
text: editQuoteData.text,
author: finalAuthor,
categories,
quoteDate:
userQuotes.find((q) => q.id === editingQuoteId)?.quoteDate || new Date().toISOString(),
});
// Animate completion
editQuoteScale.value = withSpring(1.02, {}, () => {
editQuoteScale.value = withSpring(1);
});
editQuoteOpacity.value = withTiming(1, { duration: 150 });
setEditingQuoteId(null);
setEditQuoteData({
text: '',
author: '',
categories: '',
});
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const cancelInlineEdit = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Animate back to normal
editQuoteScale.value = withSpring(1, { damping: 25, stiffness: 300 });
editQuoteOpacity.value = withTiming(1, { duration: 150 });
setEditingQuoteId(null);
setEditQuoteData({
text: '',
author: '',
categories: '',
});
};
// fabAnimatedStyle removed - not needed for current FAB implementation
const newQuoteAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: newQuoteScale.value }],
opacity: newQuoteOpacity.value,
}));
const editQuoteAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: editQuoteScale.value }],
opacity: editQuoteOpacity.value,
}));
const renderQuote = ({ item, index }: { item: EnhancedQuote; index: number }) => {
const isNewQuote = item.id === 'new-quote-temp';
const isBeingEdited = editingQuoteId === item.id;
if (isNewQuote) {
return (
<Animated.View style={newQuoteAnimatedStyle} className="mb-5">
<QuoteCard
quote={item}
variant="edit"
editMode={true}
onToggleFavorite={() => {}}
onTextChange={handleInlineTextChange}
onAuthorChange={handleInlineAuthorChange}
onCategoryChange={handleInlineCategoryChange}
onSave={saveInlineQuote}
onCancel={cancelInlineCreation}
/>
</Animated.View>
);
}
// Find original UserQuote for existing quotes
const originalQuote = userQuotes.find((q) => q.id === item.id);
if (!originalQuote) return null;
// If this quote is being edited inline, show edit mode
if (isBeingEdited) {
const editQuote: EnhancedQuote = {
...item,
text: editQuoteData.text,
author: {
...item.author,
name: editQuoteData.author,
},
categories: editQuoteData.categories
.split(',')
.map((c) => c.trim())
.filter((c) => c.length > 0),
};
return (
<Animated.View style={editQuoteAnimatedStyle} className="mb-5">
<QuoteCard
quote={editQuote}
variant="edit"
editMode={true}
onToggleFavorite={() => {}}
onTextChange={handleEditTextChange}
onAuthorChange={handleEditAuthorChange}
onCategoryChange={handleEditCategoryChange}
onSave={saveInlineEdit}
onCancel={cancelInlineEdit}
/>
</Animated.View>
);
}
// Use different variant based on viewMode
if (viewMode === 'card') {
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
cardHeight={600} // Adjust height as needed
onToggleFavorite={() => toggleUserQuoteFavorite(originalQuote.id)}
onAuthorPress={() => {}}
onEdit={() => startInlineEdit(originalQuote)}
onDelete={() => handleDelete(originalQuote.id)}
/>
</View>
);
}
// List view (default)
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={() => toggleUserQuoteFavorite(originalQuote.id)}
onAuthorPress={() => {}} // Remove author press to avoid modal
onEdit={() => startInlineEdit(originalQuote)}
onDelete={() => handleDelete(originalQuote.id)}
/>
</View>
);
};
return (
<>
<Stack.Screen
options={{
title: t('myQuotes.title'),
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'list' ? 'grid-outline' : 'list-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => router.push('/settings')}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name="settings-outline"
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
</View>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
{userQuotes.length === 0 && !isCreatingNew ? (
<View className="flex-1 justify-center items-center px-6" style={{ paddingTop: 100 }}>
<Icon
name="create-outline"
size={64}
color={isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}
/>
<Text
className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-lg mt-4 text-center font-semibold`}
>
{t('myQuotes.noQuotes')}
</Text>
<Text
className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm mt-2 text-center`}
>
{t('myQuotes.createFirst')}
</Text>
</View>
) : (
<FlatList
data={displayQuotes}
renderItem={renderQuote}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: LIST_CONTAINER_PADDING.bottom,
paddingTop: LIST_CONTAINER_PADDING.top,
}}
pagingEnabled={viewMode === 'card'}
snapToInterval={viewMode === 'card' ? 600 : undefined}
snapToAlignment={viewMode === 'card' ? 'start' : undefined}
decelerationRate={viewMode === 'card' ? 'fast' : 'normal'}
/>
)}
{/* Floating Action Button */}
{!isCreatingNew && (
<GlassFAB
onPress={startInlineCreation}
icon="add"
size="medium"
position="bottom-right"
/>
)}
{/* Modal-based editing removed - now using inline editing */}
</View>
</>
);
}

View file

@ -1,14 +1,14 @@
import { Stack } from 'expo-router';
export default function QuotesLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: false,
}}
/>
</Stack>
);
}

View file

@ -1,5 +1,12 @@
import { useRouter, useLocalSearchParams, Stack } from 'expo-router';
import { View, Text, ActivityIndicator, Dimensions, FlatList, TouchableOpacity } from 'react-native';
import {
View,
Text,
ActivityIndicator,
Dimensions,
FlatList,
TouchableOpacity,
} from 'react-native';
import { useQuotesStore } from '~/store/quotesStore';
import QuoteCard from '~/components/QuoteCard';
import * as Haptics from 'expo-haptics';
@ -8,15 +15,16 @@ import { Icon } from '~/components/Icon';
import { useIsDarkMode } from '~/store/settingsStore';
import { useTranslation } from 'react-i18next';
import { GlassTabSelector } from '~/components/common/GlassTabSelector';
import Animated, {
useSharedValue,
useAnimatedScrollHandler
} from 'react-native-reanimated';
import Animated, { useSharedValue, useAnimatedScrollHandler } from 'react-native-reanimated';
import { LIST_CONTAINER_PADDING } from '~/constants/layout';
import { useTheme } from '~/hooks/useTheme';
import { QuoteFilterSheet } from '~/components/quotes/QuoteFilterSheet';
import { ActiveQuoteFilterChips } from '~/components/quotes/ActiveQuoteFilterChips';
import { filterQuotes, QuoteFilters, hasActiveFilters as checkHasActiveFilters } from '~/utils/quoteFilters';
import {
filterQuotes,
QuoteFilters,
hasActiveFilters as checkHasActiveFilters,
} from '~/utils/quoteFilters';
import BottomSheet from '@gorhom/bottom-sheet';
const { height: screenHeight } = Dimensions.get('window');
@ -26,465 +34,492 @@ const TAB_BAR_HEIGHT = 80; // Tab bar height
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
export default function Home() {
const router = useRouter();
const params = useLocalSearchParams();
const { widgetQuoteId, q: searchQuery } = params;
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const [displayedQuotes, setDisplayedQuotes] = useState([]);
const [activeFilter, setActiveFilter] = useState<'recommended' | 'favorites'>('recommended');
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [filters, setFilters] = useState<QuoteFilters>({
timePeriods: [],
sourceTypes: [],
categories: [],
authorEras: [],
special: []
});
const flatListRef = useRef(null);
const bottomSheetRef = useRef<BottomSheet>(null);
const usedQuoteIds = useRef(new Set());
const scrollY = useSharedValue(0);
const {
quotes,
toggleFavorite,
getFavorites,
isLoading,
isInitialized
} = useQuotesStore();
const router = useRouter();
const params = useLocalSearchParams();
const { widgetQuoteId, q: searchQuery } = params;
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const [displayedQuotes, setDisplayedQuotes] = useState([]);
const [activeFilter, setActiveFilter] = useState<'recommended' | 'favorites'>('recommended');
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [filters, setFilters] = useState<QuoteFilters>({
timePeriods: [],
sourceTypes: [],
categories: [],
authorEras: [],
special: [],
});
const flatListRef = useRef(null);
const bottomSheetRef = useRef<BottomSheet>(null);
const usedQuoteIds = useRef(new Set());
const scrollY = useSharedValue(0);
// Filter quotes based on active filter, advanced filters, and search query
const filteredQuotes = useMemo(() => {
// Safety check: ensure quotes array exists
if (!quotes || !Array.isArray(quotes)) {
return [];
}
const { quotes, toggleFavorite, getFavorites, isLoading, isInitialized } = useQuotesStore();
let quotesToFilter = activeFilter === 'favorites' ? getFavorites() : quotes;
// Filter quotes based on active filter, advanced filters, and search query
const filteredQuotes = useMemo(() => {
// Safety check: ensure quotes array exists
if (!quotes || !Array.isArray(quotes)) {
return [];
}
// Apply advanced filters
quotesToFilter = filterQuotes(quotesToFilter, filters);
let quotesToFilter = activeFilter === 'favorites' ? getFavorites() : quotes;
// Then apply search query if present
if (searchQuery && typeof searchQuery === 'string' && searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
quotesToFilter = quotesToFilter.filter(quote =>
quote.text.toLowerCase().includes(query) ||
(quote.author?.name && quote.author.name.toLowerCase().includes(query)) ||
(quote.tags && quote.tags.some((tag: string) => tag.toLowerCase().includes(query)))
);
}
// Apply advanced filters
quotesToFilter = filterQuotes(quotesToFilter, filters);
return quotesToFilter;
}, [quotes, activeFilter, getFavorites, searchQuery, filters]);
// Then apply search query if present
if (searchQuery && typeof searchQuery === 'string' && searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
quotesToFilter = quotesToFilter.filter(
(quote) =>
quote.text.toLowerCase().includes(query) ||
(quote.author?.name && quote.author.name.toLowerCase().includes(query)) ||
(quote.tags && quote.tags.some((tag: string) => tag.toLowerCase().includes(query)))
);
}
// Height calculations
const TOTAL_TOP_HEIGHT = STATUS_BAR_HEIGHT;
const TAB_SELECTOR_HEIGHT = 60; // Height of TabSelector component
const AVAILABLE_HEIGHT = screenHeight - TOTAL_TOP_HEIGHT - TAB_BAR_HEIGHT - TAB_SELECTOR_HEIGHT;
const CARD_HEIGHT = AVAILABLE_HEIGHT * 0.9; // Cards take 90% of available height
const VERTICAL_PADDING = (AVAILABLE_HEIGHT - CARD_HEIGHT) / 2; // Center padding
return quotesToFilter;
}, [quotes, activeFilter, getFavorites, searchQuery, filters]);
// Handle widget deep link
useEffect(() => {
if (widgetQuoteId && quotes.length > 0 && displayedQuotes.length > 0) {
// Find quote by hash value (matching Swift's hashValue)
const targetQuote = quotes.find(q => {
// Simple hash calculation that should be more consistent
const hash = Math.abs(
q.text.split('').reduce((a: number, b: string) => {
a = ((a << 5) - a) + b.charCodeAt(0);
return a & a;
}, 0)
);
return String(hash) === String(widgetQuoteId);
});
// Height calculations
const TOTAL_TOP_HEIGHT = STATUS_BAR_HEIGHT;
const TAB_SELECTOR_HEIGHT = 60; // Height of TabSelector component
const AVAILABLE_HEIGHT = screenHeight - TOTAL_TOP_HEIGHT - TAB_BAR_HEIGHT - TAB_SELECTOR_HEIGHT;
const CARD_HEIGHT = AVAILABLE_HEIGHT * 0.9; // Cards take 90% of available height
const VERTICAL_PADDING = (AVAILABLE_HEIGHT - CARD_HEIGHT) / 2; // Center padding
if (targetQuote) {
// Handle widget deep link
useEffect(() => {
if (widgetQuoteId && quotes.length > 0 && displayedQuotes.length > 0) {
// Find quote by hash value (matching Swift's hashValue)
const targetQuote = quotes.find((q) => {
// Simple hash calculation that should be more consistent
const hash = Math.abs(
q.text.split('').reduce((a: number, b: string) => {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0)
);
return String(hash) === String(widgetQuoteId);
});
// Check if quote is already in displayed quotes
const existingIndex = displayedQuotes.findIndex((q: any) => q.id === targetQuote.id);
if (existingIndex !== -1) {
// Quote already displayed, scroll to it
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: existingIndex,
animated: true
});
}, 500);
} else {
// Add quote to beginning of list (after daily quote)
setDisplayedQuotes(prev => [targetQuote, ...prev]);
usedQuoteIds.current.add(targetQuote.id);
// Scroll to the new quote
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: 0,
animated: true
});
}, 500);
}
// Haptic feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}
}, [widgetQuoteId, quotes, displayedQuotes]);
if (targetQuote) {
// Check if quote is already in displayed quotes
const existingIndex = displayedQuotes.findIndex((q: any) => q.id === targetQuote.id);
// Initialize with random quotes when quotes are loaded
useEffect(() => {
// Only run once when initialized and quotes are available
if (!isInitialized || !quotes || quotes.length === 0 || displayedQuotes.length > 0) {
return;
}
if (existingIndex !== -1) {
// Quote already displayed, scroll to it
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: existingIndex,
animated: true,
});
}, 500);
} else {
// Add quote to beginning of list (after daily quote)
setDisplayedQuotes((prev) => [targetQuote, ...prev]);
usedQuoteIds.current.add(targetQuote.id);
// Add initial set of random quotes
const initialQuotes = [];
const availableQuotes = [...quotes]; // Create a copy to avoid mutating the original
// Scroll to the new quote
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: 0,
animated: true,
});
}, 500);
}
// Pre-load 5 random quotes
for (let i = 0; i < Math.min(5, availableQuotes.length); i++) {
const randomIndex = Math.floor(Math.random() * availableQuotes.length);
const quote = availableQuotes[randomIndex];
if (!usedQuoteIds.current.has(quote.id)) {
initialQuotes.push(quote);
usedQuoteIds.current.add(quote.id);
availableQuotes.splice(randomIndex, 1);
}
}
// Haptic feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}
}, [widgetQuoteId, quotes, displayedQuotes]);
if (initialQuotes.length > 0) {
setDisplayedQuotes(initialQuotes);
}
}, [isInitialized, quotes.length]); // Only depend on isInitialized and quotes.length, not quotes array
// Initialize with random quotes when quotes are loaded
useEffect(() => {
// Only run once when initialized and quotes are available
if (!isInitialized || !quotes || quotes.length === 0 || displayedQuotes.length > 0) {
return;
}
// Update displayedQuotes when quotes change (for favorites)
useEffect(() => {
if (displayedQuotes.length > 0 && quotes.length > 0 && isInitialized) {
setDisplayedQuotes(prevDisplayed =>
prevDisplayed
.filter(displayedQuote => displayedQuote && displayedQuote.id) // Filter out invalid quotes
.map(displayedQuote => {
const updatedQuote = quotes.find(q => q.id === displayedQuote.id);
return updatedQuote || displayedQuote;
})
);
}
}, [quotes, displayedQuotes.length, isInitialized]);
// Add initial set of random quotes
const initialQuotes = [];
const availableQuotes = [...quotes]; // Create a copy to avoid mutating the original
const loadMoreQuotes = useCallback(() => {
if (!quotes || quotes.length === 0) return;
const availableQuotes = quotes.filter(q => !usedQuoteIds.current.has(q.id));
if (availableQuotes.length === 0) {
// Reset and start over
usedQuoteIds.current.clear();
// Restart with new quotes
const newQuotes = [];
const freshQuotes = quotes;
for (let i = 0; i < Math.min(3, freshQuotes.length); i++) {
const randomIndex = Math.floor(Math.random() * freshQuotes.length);
const quote = freshQuotes[randomIndex];
if (!usedQuoteIds.current.has(quote.id)) {
newQuotes.push(quote);
usedQuoteIds.current.add(quote.id);
freshQuotes.splice(randomIndex, 1);
}
}
setDisplayedQuotes(prev => [...prev, ...newQuotes]);
return;
}
// Add 3 more random quotes
const newQuotes = [];
for (let i = 0; i < Math.min(3, availableQuotes.length); i++) {
const randomIndex = Math.floor(Math.random() * availableQuotes.length);
const quote = availableQuotes[randomIndex];
if (!usedQuoteIds.current.has(quote.id)) {
newQuotes.push(quote);
usedQuoteIds.current.add(quote.id);
availableQuotes.splice(randomIndex, 1);
}
}
if (newQuotes.length > 0) {
setDisplayedQuotes(prev => [...prev, ...newQuotes]);
}
}, [quotes]);
// Pre-load 5 random quotes
for (let i = 0; i < Math.min(5, availableQuotes.length); i++) {
const randomIndex = Math.floor(Math.random() * availableQuotes.length);
const quote = availableQuotes[randomIndex];
if (!usedQuoteIds.current.has(quote.id)) {
initialQuotes.push(quote);
usedQuoteIds.current.add(quote.id);
availableQuotes.splice(randomIndex, 1);
}
}
if (initialQuotes.length > 0) {
setDisplayedQuotes(initialQuotes);
}
}, [isInitialized, quotes.length]); // Only depend on isInitialized and quotes.length, not quotes array
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
// Update displayedQuotes when quotes change (for favorites)
useEffect(() => {
if (displayedQuotes.length > 0 && quotes.length > 0 && isInitialized) {
setDisplayedQuotes((prevDisplayed) =>
prevDisplayed
.filter((displayedQuote) => displayedQuote && displayedQuote.id) // Filter out invalid quotes
.map((displayedQuote) => {
const updatedQuote = quotes.find((q) => q.id === displayedQuote.id);
return updatedQuote || displayedQuote;
})
);
}
}, [quotes, displayedQuotes.length, isInitialized]);
const handleEndReached = () => {
loadMoreQuotes();
};
const loadMoreQuotes = useCallback(() => {
if (!quotes || quotes.length === 0) return;
// Author press handler - reusable
const handleAuthorPress = useCallback((quote: EnhancedQuote) => {
const authorId = quote.authorId || quote.author?.id;
if (authorId) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.push(`/author/${authorId}`);
}
}, [router]);
const availableQuotes = quotes.filter((q) => !usedQuoteIds.current.has(q.id));
// Get favorites count
const favoriteQuotes = getFavorites();
// Tab handling
const tabs = [
{ key: 'recommended', label: t('common.recommended') },
{ key: 'favorites', label: t('navigation.favorites'), count: favoriteQuotes?.length || 0 }
];
const handleTabChange = (tabKey: string) => {
setActiveFilter(tabKey as 'recommended' | 'favorites');
// Scroll to top when switching tabs
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
};
if (availableQuotes.length === 0) {
// Reset and start over
usedQuoteIds.current.clear();
// Restart with new quotes
const newQuotes = [];
const freshQuotes = quotes;
for (let i = 0; i < Math.min(3, freshQuotes.length); i++) {
const randomIndex = Math.floor(Math.random() * freshQuotes.length);
const quote = freshQuotes[randomIndex];
if (!usedQuoteIds.current.has(quote.id)) {
newQuotes.push(quote);
usedQuoteIds.current.add(quote.id);
freshQuotes.splice(randomIndex, 1);
}
}
setDisplayedQuotes((prev) => [...prev, ...newQuotes]);
return;
}
// Filter handlers
const handleRemoveFilter = (category: keyof QuoteFilters, value: string) => {
setFilters(prev => ({
...prev,
[category]: prev[category].filter(v => v !== value)
}));
};
// Add 3 more random quotes
const newQuotes = [];
for (let i = 0; i < Math.min(3, availableQuotes.length); i++) {
const randomIndex = Math.floor(Math.random() * availableQuotes.length);
const quote = availableQuotes[randomIndex];
if (!usedQuoteIds.current.has(quote.id)) {
newQuotes.push(quote);
usedQuoteIds.current.add(quote.id);
availableQuotes.splice(randomIndex, 1);
}
}
const handleClearAllFilters = () => {
setFilters({
timePeriods: [],
sourceTypes: [],
categories: [],
authorEras: [],
special: []
});
};
if (newQuotes.length > 0) {
setDisplayedQuotes((prev) => [...prev, ...newQuotes]);
}
}, [quotes]);
const hasActiveFilters = checkHasActiveFilters(filters);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
// Use filtered quotes when searching, when favorites filter is active, or when filters are applied
const quotesToShow = (searchQuery && searchQuery.trim()) || activeFilter === 'favorites' || hasActiveFilters ? filteredQuotes : displayedQuotes;
const allQuotes = quotesToShow;
const handleEndReached = () => {
loadMoreQuotes();
};
// Author press handler - reusable
const handleAuthorPress = useCallback(
(quote: EnhancedQuote) => {
const authorId = quote.authorId || quote.author?.id;
if (authorId) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.push(`/author/${authorId}`);
}
},
[router]
);
// Optimierung für FlatList mit getItemLayout
const getItemLayout = useCallback((data: any, index: number) => {
return {
length: CARD_HEIGHT,
offset: CARD_HEIGHT * index,
index
};
}, [CARD_HEIGHT]);
// Get favorites count
const favoriteQuotes = getFavorites();
const renderQuote = ({ item, index }: { item: EnhancedQuote; index: number }) => {
// Use list view if viewMode is 'list'
if (viewMode === 'list') {
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => handleAuthorPress(item)}
/>
</View>
);
}
// Tab handling
const tabs = [
{ key: 'recommended', label: t('common.recommended') },
{ key: 'favorites', label: t('navigation.favorites'), count: favoriteQuotes?.length || 0 },
];
// Otherwise use vertical card view
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
scrollY={scrollY}
cardHeight={CARD_HEIGHT}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => handleAuthorPress(item)}
/>
</View>
);
};
const handleTabChange = (tabKey: string) => {
setActiveFilter(tabKey as 'recommended' | 'favorites');
// Scroll to top when switching tabs
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
};
// Show loading state while store is initializing
if (!isInitialized || isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} mt-4`}>{t('quotes.loadingQuotes')}</Text>
</View>
);
}
// Filter handlers
const handleRemoveFilter = (category: keyof QuoteFilters, value: string) => {
setFilters((prev) => ({
...prev,
[category]: prev[category].filter((v) => v !== value),
}));
};
// Show loading state while store is initializing
if (!isInitialized || isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color={isDarkMode ? '#ffffff' : '#000000'} />
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mt-4`}>{t('common.loading')}</Text>
</View>
);
}
const handleClearAllFilters = () => {
setFilters({
timePeriods: [],
sourceTypes: [],
categories: [],
authorEras: [],
special: [],
});
};
// Check if quotes are loaded after initialization
if (!quotes || !Array.isArray(quotes) || quotes.length === 0) {
return (
<View style={{ flex: 1, backgroundColor: colors.background, justifyContent: 'center', alignItems: 'center' }}>
<Text className={isDarkMode ? 'text-gray-400' : 'text-gray-600'}>{t('quotes.noQuotes')}</Text>
</View>
);
}
const hasActiveFilters = checkHasActiveFilters(filters);
return (
<>
<Stack.Screen
options={{
title: activeFilter === 'favorites' ? t('navigation.favorites') : t('navigation.quotes'),
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerTitleAlign: 'center',
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
bottomSheetRef.current?.snapToIndex(0);
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name="filter-outline"
size={24}
color={hasActiveFilters ? '#7c3aed' : (isDarkMode ? '#ffffff' : '#000000')}
/>
{hasActiveFilters && (
<View style={{
position: 'absolute',
top: 8,
right: 8,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#7c3aed'
}} />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'card' ? 'list-outline' : 'grid-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
</View>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
// Use filtered quotes when searching, when favorites filter is active, or when filters are applied
const quotesToShow =
(searchQuery && searchQuery.trim()) || activeFilter === 'favorites' || hasActiveFilters
? filteredQuotes
: displayedQuotes;
const allQuotes = quotesToShow;
<View style={{ flex: 1, backgroundColor: colors.background }}>
{/* Active Filter Chips */}
{hasActiveFilters && (
<ActiveQuoteFilterChips
filters={filters}
onRemoveFilter={handleRemoveFilter}
onClearAll={handleClearAllFilters}
/>
)}
// Optimierung für FlatList mit getItemLayout
const getItemLayout = useCallback(
(data: any, index: number) => {
return {
length: CARD_HEIGHT,
offset: CARD_HEIGHT * index,
index,
};
},
[CARD_HEIGHT]
);
{/* Main Content */}
<AnimatedFlatList
ref={flatListRef}
data={allQuotes}
renderItem={renderQuote}
keyExtractor={(item, index) => `${item.id}-${index}`}
horizontal={false}
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{
paddingTop: hasActiveFilters ? 24 : LIST_CONTAINER_PADDING.top,
paddingBottom: viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom + 80 : AVAILABLE_HEIGHT - VERTICAL_PADDING
}}
snapToInterval={viewMode === 'list' ? undefined : CARD_HEIGHT}
snapToAlignment={viewMode === 'list' ? undefined : "start"}
decelerationRate={viewMode === 'list' ? "normal" : "fast"}
getItemLayout={viewMode === 'list' ? undefined : getItemLayout}
removeClippedSubviews={true}
initialNumToRender={3}
maxToRenderPerBatch={5}
windowSize={10}
ListFooterComponent={
allQuotes.length > 0 && activeFilter === 'recommended' ? (
<View className="py-8">
<ActivityIndicator size="small" color={isDarkMode ? '#ffffff' : '#000000'} />
<Text className={`${isDarkMode ? 'text-white/30' : 'text-black/30'} text-xs mt-2`}>
{t('quotes.loadMore')}
</Text>
</View>
) : null
}
/>
{/* Glass Tab Selector at bottom - positioned above tab bar */}
<View className="absolute bottom-24 left-0 right-0 z-10">
<GlassTabSelector
tabs={tabs}
activeTab={activeFilter}
onTabChange={handleTabChange}
/>
</View>
</View>
const renderQuote = ({ item, index }: { item: EnhancedQuote; index: number }) => {
// Use list view if viewMode is 'list'
if (viewMode === 'list') {
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => handleAuthorPress(item)}
/>
</View>
);
}
{/* Filter Sheet */}
<QuoteFilterSheet
bottomSheetRef={bottomSheetRef}
filters={filters}
onFiltersChange={setFilters}
onClearAll={handleClearAllFilters}
/>
</>
);
}
// Otherwise use vertical card view
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
scrollY={scrollY}
cardHeight={CARD_HEIGHT}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => handleAuthorPress(item)}
/>
</View>
);
};
// Show loading state while store is initializing
if (!isInitialized || isLoading) {
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
}}
>
<ActivityIndicator size="large" color={colors.primary} />
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} mt-4`}>
{t('quotes.loadingQuotes')}
</Text>
</View>
);
}
// Show loading state while store is initializing
if (!isInitialized || isLoading) {
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
}}
>
<ActivityIndicator size="large" color={isDarkMode ? '#ffffff' : '#000000'} />
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-600'} mt-4`}>
{t('common.loading')}
</Text>
</View>
);
}
// Check if quotes are loaded after initialization
if (!quotes || !Array.isArray(quotes) || quotes.length === 0) {
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text className={isDarkMode ? 'text-gray-400' : 'text-gray-600'}>
{t('quotes.noQuotes')}
</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: activeFilter === 'favorites' ? t('navigation.favorites') : t('navigation.quotes'),
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerTitleAlign: 'center',
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
bottomSheetRef.current?.snapToIndex(0);
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name="filter-outline"
size={24}
color={hasActiveFilters ? '#7c3aed' : isDarkMode ? '#ffffff' : '#000000'}
/>
{hasActiveFilters && (
<View
style={{
position: 'absolute',
top: 8,
right: 8,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#7c3aed',
}}
/>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'card' ? 'list-outline' : 'grid-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
</View>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
{/* Active Filter Chips */}
{hasActiveFilters && (
<ActiveQuoteFilterChips
filters={filters}
onRemoveFilter={handleRemoveFilter}
onClearAll={handleClearAllFilters}
/>
)}
{/* Main Content */}
<AnimatedFlatList
ref={flatListRef}
data={allQuotes}
renderItem={renderQuote}
keyExtractor={(item, index) => `${item.id}-${index}`}
horizontal={false}
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{
paddingTop: hasActiveFilters ? 24 : LIST_CONTAINER_PADDING.top,
paddingBottom:
viewMode === 'list'
? LIST_CONTAINER_PADDING.bottom + 80
: AVAILABLE_HEIGHT - VERTICAL_PADDING,
}}
snapToInterval={viewMode === 'list' ? undefined : CARD_HEIGHT}
snapToAlignment={viewMode === 'list' ? undefined : 'start'}
decelerationRate={viewMode === 'list' ? 'normal' : 'fast'}
getItemLayout={viewMode === 'list' ? undefined : getItemLayout}
removeClippedSubviews={true}
initialNumToRender={3}
maxToRenderPerBatch={5}
windowSize={10}
ListFooterComponent={
allQuotes.length > 0 && activeFilter === 'recommended' ? (
<View className="py-8">
<ActivityIndicator size="small" color={isDarkMode ? '#ffffff' : '#000000'} />
<Text className={`${isDarkMode ? 'text-white/30' : 'text-black/30'} text-xs mt-2`}>
{t('quotes.loadMore')}
</Text>
</View>
) : null
}
/>
{/* Glass Tab Selector at bottom - positioned above tab bar */}
<View className="absolute bottom-24 left-0 right-0 z-10">
<GlassTabSelector tabs={tabs} activeTab={activeFilter} onTabChange={handleTabChange} />
</View>
</View>
{/* Filter Sheet */}
<QuoteFilterSheet
bottomSheetRef={bottomSheetRef}
filters={filters}
onFiltersChange={setFilters}
onClearAll={handleClearAllFilters}
/>
</>
);
}

View file

@ -4,86 +4,86 @@ import usePremiumStore from '~/store/premiumStore';
import { Alert } from 'react-native';
export default function SearchLayout() {
const router = useRouter();
const [showingLimitAlert, setShowingLimitAlert] = useState(false);
const lastSearchText = useRef('');
const lastCountedSearch = useRef('');
const { canSearch, useSearch, getRemainingSearches, MAX_DAILY_SEARCHES } = usePremiumStore();
const router = useRouter();
const [showingLimitAlert, setShowingLimitAlert] = useState(false);
const lastSearchText = useRef('');
const lastCountedSearch = useRef('');
const { canSearch, useSearch, getRemainingSearches, MAX_DAILY_SEARCHES } = usePremiumStore();
// Live update search query without counting
const handleTextChange = (text: string) => {
// Always update the search query for live filtering
router.setParams({ q: text || '' });
lastSearchText.current = text;
};
// Live update search query without counting
const handleTextChange = (text: string) => {
// Always update the search query for live filtering
router.setParams({ q: text || '' });
lastSearchText.current = text;
};
// Count search only when user submits or leaves the search bar
const handleSearchSubmit = (text: string) => {
// Don't count empty searches or clearing
if (!text || text.length === 0) {
lastCountedSearch.current = '';
return;
}
// Count search only when user submits or leaves the search bar
const handleSearchSubmit = (text: string) => {
// Don't count empty searches or clearing
if (!text || text.length === 0) {
lastCountedSearch.current = '';
return;
}
// If same as last counted search, don't count again
if (text === lastCountedSearch.current) {
return;
}
// If same as last counted search, don't count again
if (text === lastCountedSearch.current) {
return;
}
// Check if user can search
if (!canSearch()) {
if (!showingLimitAlert) {
setShowingLimitAlert(true);
const remaining = getRemainingSearches();
Alert.alert(
'Such-Limit erreicht',
`Du hast deine ${MAX_DAILY_SEARCHES} täglichen Suchen aufgebraucht.\n\nMit Zitare Premium kannst du unbegrenzt suchen!`,
[
{ text: 'Später', style: 'cancel', onPress: () => setShowingLimitAlert(false) },
{
text: 'Premium werden',
onPress: () => {
setShowingLimitAlert(false);
router.push('/paywall');
}
}
]
);
}
// Reset to last valid search
router.setParams({ q: lastCountedSearch.current });
lastSearchText.current = lastCountedSearch.current;
return;
}
// Check if user can search
if (!canSearch()) {
if (!showingLimitAlert) {
setShowingLimitAlert(true);
const remaining = getRemainingSearches();
Alert.alert(
'Such-Limit erreicht',
`Du hast deine ${MAX_DAILY_SEARCHES} täglichen Suchen aufgebraucht.\n\nMit Zitare Premium kannst du unbegrenzt suchen!`,
[
{ text: 'Später', style: 'cancel', onPress: () => setShowingLimitAlert(false) },
{
text: 'Premium werden',
onPress: () => {
setShowingLimitAlert(false);
router.push('/paywall');
},
},
]
);
}
// Reset to last valid search
router.setParams({ q: lastCountedSearch.current });
lastSearchText.current = lastCountedSearch.current;
return;
}
// Count this search
if (useSearch()) {
lastCountedSearch.current = text;
}
};
// Count this search
if (useSearch()) {
lastCountedSearch.current = text;
}
};
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true, // Must be true for search bar to show
title: '', // Empty title for clean look
headerTransparent: true, // Make header transparent
headerSearchBarOptions: {
placement: 'automatic',
placeholder: 'Search quotes, authors...',
// Update search query live as user types (no counting)
onChangeText: (event) => {
handleTextChange(event.nativeEvent.text);
},
// Count search ONLY when user submits (presses Enter/Search button)
onSearchButtonPress: (event) => {
handleSearchSubmit(event.nativeEvent.text);
},
},
}}
/>
</Stack>
);
}
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true, // Must be true for search bar to show
title: '', // Empty title for clean look
headerTransparent: true, // Make header transparent
headerSearchBarOptions: {
placement: 'automatic',
placeholder: 'Search quotes, authors...',
// Update search query live as user types (no counting)
onChangeText: (event) => {
handleTextChange(event.nativeEvent.text);
},
// Count search ONLY when user submits (presses Enter/Search button)
onSearchButtonPress: (event) => {
handleSearchSubmit(event.nativeEvent.text);
},
},
}}
/>
</Stack>
);
}

View file

@ -6,11 +6,11 @@ import { useEffect, useMemo, useState } from 'react';
import { Icon } from '~/components/Icon';
import * as Haptics from 'expo-haptics';
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolate
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolate,
} from 'react-native-reanimated';
import { useQuotesStore } from '~/store/quotesStore';
import { useIsDarkMode } from '~/store/settingsStore';
@ -23,219 +23,241 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const { height: screenHeight } = Dimensions.get('window');
export default function SearchIndex() {
const { t } = useTranslation();
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const params = useLocalSearchParams();
const { q: searchQuery } = params;
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
const scrollY = useSharedValue(0);
// Calculate card height for vertical mode
const TAB_BAR_HEIGHT = 80;
const STATUS_BAR_HEIGHT = 44;
const CARD_HEIGHT = screenHeight - STATUS_BAR_HEIGHT - TAB_BAR_HEIGHT - 100;
const { t } = useTranslation();
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const params = useLocalSearchParams();
const { q: searchQuery } = params;
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
const scrollY = useSharedValue(0);
const { quotes, authors, toggleFavorite, initializeStore, isLoading } = useQuotesStore();
// Calculate card height for vertical mode
const TAB_BAR_HEIGHT = 80;
const STATUS_BAR_HEIGHT = 44;
const CARD_HEIGHT = screenHeight - STATUS_BAR_HEIGHT - TAB_BAR_HEIGHT - 100;
useEffect(() => {
initializeStore();
}, []);
const { quotes, authors, toggleFavorite, initializeStore, isLoading } = useQuotesStore();
// Filter quotes based on search query
const filteredQuotes = useMemo(() => {
if (!searchQuery || typeof searchQuery !== 'string') {
return quotes; // Show all quotes when no search query
}
useEffect(() => {
initializeStore();
}, []);
const query = searchQuery.toLowerCase().trim();
if (!query) {
return quotes;
}
// Filter quotes based on search query
const filteredQuotes = useMemo(() => {
if (!searchQuery || typeof searchQuery !== 'string') {
return quotes; // Show all quotes when no search query
}
return quotes.filter(quote =>
quote.text.toLowerCase().includes(query) ||
(quote.author?.name && quote.author.name.toLowerCase().includes(query)) ||
(quote.tags && quote.tags.some(tag => tag.toLowerCase().includes(query))) ||
(quote.categories && quote.categories.some(cat => cat.toLowerCase().includes(query)))
);
}, [quotes, searchQuery]);
const query = searchQuery.toLowerCase().trim();
if (!query) {
return quotes;
}
const renderQuote = ({ item, index }) => {
if (viewMode === 'card') {
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
scrollY={scrollY}
cardHeight={CARD_HEIGHT}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
}
}}
/>
</View>
);
}
// List view
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
}
}}
/>
</View>
);
};
return quotes.filter(
(quote) =>
quote.text.toLowerCase().includes(query) ||
(quote.author?.name && quote.author.name.toLowerCase().includes(query)) ||
(quote.tags && quote.tags.some((tag) => tag.toLowerCase().includes(query))) ||
(quote.categories && quote.categories.some((cat) => cat.toLowerCase().includes(query)))
);
}, [quotes, searchQuery]);
const renderEmptyState = () => {
if (searchQuery && typeof searchQuery === 'string' && searchQuery.trim()) {
// No results for search
return (
<View className="flex-1 justify-center items-center py-20 px-8">
<Ionicons
name="search-outline"
size={64}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-lg font-semibold text-center mt-4`}>
{t('search.noResults', { defaultValue: 'No Results Found' })}
</Text>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-base text-center mt-2`}>
No quotes found for "{searchQuery}"
</Text>
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm text-center mt-4`}>
Try searching for different keywords or authors
</Text>
</View>
);
}
const renderQuote = ({ item, index }) => {
if (viewMode === 'card') {
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
scrollY={scrollY}
cardHeight={CARD_HEIGHT}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
}
}}
/>
</View>
);
}
// Welcome state when no search
return (
<View className="flex-1 justify-center items-center py-20 px-8">
<Ionicons
name="search"
size={80}
color={isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}
/>
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-xl font-bold text-center mt-6`}>
{t('search.title', { defaultValue: 'Search Quotes' })}
</Text>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-base text-center mt-2`}>
{t('search.description', { defaultValue: 'Use the search bar above to find quotes by text, author, or topic' })}
</Text>
</View>
);
};
// List view
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
}
}}
/>
</View>
);
};
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View className="flex-1 justify-center items-center">
<Text className={`${isDarkMode ? 'text-white' : 'text-black'}`}>
{t('quotes.loadingQuotes', { defaultValue: 'Loading quotes...' })}
</Text>
</View>
</View>
);
}
const renderEmptyState = () => {
if (searchQuery && typeof searchQuery === 'string' && searchQuery.trim()) {
// No results for search
return (
<View className="flex-1 justify-center items-center py-20 px-8">
<Ionicons
name="search-outline"
size={64}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
<Text
className={`${isDarkMode ? 'text-white' : 'text-black'} text-lg font-semibold text-center mt-4`}
>
{t('search.noResults', { defaultValue: 'No Results Found' })}
</Text>
<Text
className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-base text-center mt-2`}
>
No quotes found for "{searchQuery}"
</Text>
<Text
className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm text-center mt-4`}
>
Try searching for different keywords or authors
</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: t('navigation.search'),
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerTitleAlign: 'center',
headerRight: () => (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'list' ? 'grid-outline' : 'list-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<AnimatedFlatList
data={filteredQuotes}
renderItem={renderQuote}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={
filteredQuotes.length === 0
? { flex: 1 }
: {
paddingTop: LIST_CONTAINER_PADDING.top,
paddingBottom: viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom : CARD_HEIGHT * 0.1
}
}
ListHeaderComponent={
searchQuery && filteredQuotes.length > 0 ? (
<View className="items-center mb-4">
<View className={`${isDarkMode ? 'bg-white/10 backdrop-blur-md border border-white/20' : 'bg-black/10 backdrop-blur-md border border-black/20'} px-4 py-2 rounded-full`}>
<Text className={`${isDarkMode ? 'text-white/90' : 'text-black/90'} text-sm font-medium`}>
{filteredQuotes.length} {filteredQuotes.length === 1 ? 'result' : 'results'}
</Text>
</View>
</View>
) : null
}
ListEmptyComponent={renderEmptyState}
onScroll={useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
})}
scrollEventThrottle={16}
pagingEnabled={viewMode === 'card'}
snapToInterval={viewMode === 'card' ? CARD_HEIGHT : undefined}
snapToAlignment={viewMode === 'card' ? "start" : undefined}
decelerationRate={viewMode === 'card' ? "fast" : "normal"}
getItemLayout={viewMode === 'card' ? (data, index) => ({
length: CARD_HEIGHT,
offset: CARD_HEIGHT * index,
index
}) : undefined}
/>
</View>
</>
);
}
// Welcome state when no search
return (
<View className="flex-1 justify-center items-center py-20 px-8">
<Ionicons
name="search"
size={80}
color={isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}
/>
<Text
className={`${isDarkMode ? 'text-white' : 'text-black'} text-xl font-bold text-center mt-6`}
>
{t('search.title', { defaultValue: 'Search Quotes' })}
</Text>
<Text
className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-base text-center mt-2`}
>
{t('search.description', {
defaultValue: 'Use the search bar above to find quotes by text, author, or topic',
})}
</Text>
</View>
);
};
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View className="flex-1 justify-center items-center">
<Text className={`${isDarkMode ? 'text-white' : 'text-black'}`}>
{t('quotes.loadingQuotes', { defaultValue: 'Loading quotes...' })}
</Text>
</View>
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: t('navigation.search'),
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerTitleAlign: 'center',
headerRight: () => (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'list' ? 'grid-outline' : 'list-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<AnimatedFlatList
data={filteredQuotes}
renderItem={renderQuote}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
contentContainerStyle={
filteredQuotes.length === 0
? { flex: 1 }
: {
paddingTop: LIST_CONTAINER_PADDING.top,
paddingBottom:
viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom : CARD_HEIGHT * 0.1,
}
}
ListHeaderComponent={
searchQuery && filteredQuotes.length > 0 ? (
<View className="items-center mb-4">
<View
className={`${isDarkMode ? 'bg-white/10 backdrop-blur-md border border-white/20' : 'bg-black/10 backdrop-blur-md border border-black/20'} px-4 py-2 rounded-full`}
>
<Text
className={`${isDarkMode ? 'text-white/90' : 'text-black/90'} text-sm font-medium`}
>
{filteredQuotes.length} {filteredQuotes.length === 1 ? 'result' : 'results'}
</Text>
</View>
</View>
) : null
}
ListEmptyComponent={renderEmptyState}
onScroll={useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
})}
scrollEventThrottle={16}
pagingEnabled={viewMode === 'card'}
snapToInterval={viewMode === 'card' ? CARD_HEIGHT : undefined}
snapToAlignment={viewMode === 'card' ? 'start' : undefined}
decelerationRate={viewMode === 'card' ? 'fast' : 'normal'}
getItemLayout={
viewMode === 'card'
? (data, index) => ({
length: CARD_HEIGHT,
offset: CARD_HEIGHT * index,
index,
})
: undefined
}
/>
</View>
</>
);
}

View file

@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `

View file

@ -4,23 +4,23 @@ import { Text, View } from 'react-native';
import { useTranslation } from 'react-i18next';
export default function NotFoundScreen() {
const { t } = useTranslation();
return (
<>
<Stack.Screen options={{ title: t('errors.oops') }} />
<View className={styles.container}>
<Text className={styles.title}>{t('errors.pageNotFound')}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>{t('errors.goHome')}</Text>
</Link>
</View>
</>
);
const { t } = useTranslation();
return (
<>
<Stack.Screen options={{ title: t('errors.oops') }} />
<View className={styles.container}>
<Text className={styles.title}>{t('errors.pageNotFound')}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>{t('errors.goHome')}</Text>
</Link>
</View>
</>
);
}
const styles = {
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -1,7 +1,7 @@
import '../global.css';
import '../i18n/config';
import { Stack , router } from 'expo-router';
import { Stack, router } from 'expo-router';
import { useEffect, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import * as Linking from 'expo-linking';
@ -14,163 +14,172 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { DataBackupService } from '~/services/dataBackup';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
export default function RootLayout() {
const [isStoreReady, setIsStoreReady] = useState(false);
const { checkPremiumStatus, checkAndResetLimits } = usePremiumStore();
const { shouldShowOnboarding } = useOnboardingStore();
const { initializeStore } = useQuotesStore();
const [isStoreReady, setIsStoreReady] = useState(false);
const { checkPremiumStatus, checkAndResetLimits } = usePremiumStore();
const { shouldShowOnboarding } = useOnboardingStore();
const { initializeStore } = useQuotesStore();
// Wait for stores to be ready
useEffect(() => {
const initStores = async () => {
try {
// WICHTIG: Warte auf Zustand Rehydration, bevor wir initialisieren
// Dies verhindert Race Conditions auf echten Geräten
console.log('[App] Waiting for store hydration...');
// Wait for stores to be ready
useEffect(() => {
const initStores = async () => {
try {
// WICHTIG: Warte auf Zustand Rehydration, bevor wir initialisieren
// Dies verhindert Race Conditions auf echten Geräten
console.log('[App] Waiting for store hydration...');
// Poll für hasHydrated flag (max 5 Sekunden)
let attempts = 0;
const maxAttempts = 50; // 50 * 100ms = 5 Sekunden
// Poll für hasHydrated flag (max 5 Sekunden)
let attempts = 0;
const maxAttempts = 50; // 50 * 100ms = 5 Sekunden
while (!useQuotesStore.getState().hasHydrated && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
while (!useQuotesStore.getState().hasHydrated && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (attempts >= maxAttempts) {
console.warn('[App] Hydration timeout, proceeding anyway');
} else {
console.log('[App] Store hydrated successfully');
}
if (attempts >= maxAttempts) {
console.warn('[App] Hydration timeout, proceeding anyway');
} else {
console.log('[App] Store hydrated successfully');
}
// WICHTIG: Backup-Check NACH Rehydration, damit persistierte Daten verfügbar sind
console.log('[App] Checking for data backup...');
await DataBackupService.checkAndRestoreIfNeeded();
// WICHTIG: Backup-Check NACH Rehydration, damit persistierte Daten verfügbar sind
console.log('[App] Checking for data backup...');
await DataBackupService.checkAndRestoreIfNeeded();
// Initialize quotes store
console.log('[App] Initializing quotes store...');
await initializeStore();
console.log('[App] Quotes store initialized');
// Initialize quotes store
console.log('[App] Initializing quotes store...');
await initializeStore();
console.log('[App] Quotes store initialized');
// Give AsyncStorage time to fully settle
await new Promise(resolve => setTimeout(resolve, 50));
setIsStoreReady(true);
} catch (error) {
console.error('Error initializing stores:', error);
// Retry initialization after a delay instead of proceeding with uninitialized state
setTimeout(() => {
console.log('[App] Retrying initialization...');
initStores();
}, 1000);
}
};
// Give AsyncStorage time to fully settle
await new Promise((resolve) => setTimeout(resolve, 50));
setIsStoreReady(true);
} catch (error) {
console.error('Error initializing stores:', error);
// Retry initialization after a delay instead of proceeding with uninitialized state
setTimeout(() => {
console.log('[App] Retrying initialization...');
initStores();
}, 1000);
}
};
initStores();
}, []);
initStores();
}, []);
useEffect(() => {
if (!isStoreReady) return;
useEffect(() => {
if (!isStoreReady) return;
// Initialize RevenueCat
const initRevenueCat = async () => {
try {
console.log('[App] Configuring RevenueCat...');
await RevenueCat.configure();
console.log('[App] RevenueCat configured successfully');
await checkPremiumStatus();
} catch (error) {
console.error('[App] Error initializing RevenueCat:', error);
}
};
// Initialize RevenueCat
const initRevenueCat = async () => {
try {
console.log('[App] Configuring RevenueCat...');
await RevenueCat.configure();
console.log('[App] RevenueCat configured successfully');
await checkPremiumStatus();
} catch (error) {
console.error('[App] Error initializing RevenueCat:', error);
}
};
initRevenueCat();
checkAndResetLimits();
initRevenueCat();
checkAndResetLimits();
// Check onboarding after a small delay to ensure navigation is ready
const timer = setTimeout(() => {
if (shouldShowOnboarding()) {
router.replace('/onboarding');
}
}, 100);
// Check onboarding after a small delay to ensure navigation is ready
const timer = setTimeout(() => {
if (shouldShowOnboarding()) {
router.replace('/onboarding');
}
}, 100);
return () => clearTimeout(timer);
}, [isStoreReady, checkPremiumStatus, checkAndResetLimits, shouldShowOnboarding]);
useEffect(() => {
// Handle deep links from widgets
const handleDeepLink = (url: string) => {
try {
// Parse the URL - expecting format: zitare://widget/{quoteId}
if (url.includes('zitare://widget/')) {
const quoteHash = url.split('zitare://widget/')[1];
if (quoteHash) {
// Navigate to main tab with quote parameter
router.replace({
pathname: '/(tabs)/quotes',
params: { widgetQuoteId: quoteHash }
});
}
}
} catch (error) {
console.error('Error handling deep link:', error);
// Fallback: just open the app normally
router.replace('/');
}
};
return () => clearTimeout(timer);
}, [isStoreReady, checkPremiumStatus, checkAndResetLimits, shouldShowOnboarding]);
// Listen for incoming links
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});
useEffect(() => {
// Handle deep links from widgets
const handleDeepLink = (url: string) => {
try {
// Parse the URL - expecting format: zitare://widget/{quoteId}
if (url.includes('zitare://widget/')) {
const quoteHash = url.split('zitare://widget/')[1];
// Check if app was opened from a link
Linking.getInitialURL().then(url => {
if (url) {
handleDeepLink(url);
}
});
if (quoteHash) {
// Navigate to main tab with quote parameter
router.replace({
pathname: '/(tabs)/quotes',
params: { widgetQuoteId: quoteHash },
});
}
}
} catch (error) {
console.error('Error handling deep link:', error);
// Fallback: just open the app normally
router.replace('/');
}
};
return () => subscription.remove();
}, []);
// Show loading screen while stores are initializing
if (!isStoreReady) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }}>
<ActivityIndicator size="large" color="#fff" />
</View>
</GestureHandlerRootView>
);
}
// Listen for incoming links
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ErrorBoundary
onError={(error, errorInfo) => {
// Hier könnte man Error-Logging an einen Service senden
console.error('App Error:', error, errorInfo);
}}
>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="author/[id]" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="list/[id]" options={{ headerShown: false }} />
<Stack.Screen name="paywall" options={{
headerShown: false,
presentation: 'modal',
animation: 'slide_from_bottom'
}} />
</Stack>
</ErrorBoundary>
</GestureHandlerRootView>
);
// Check if app was opened from a link
Linking.getInitialURL().then((url) => {
if (url) {
handleDeepLink(url);
}
});
return () => subscription.remove();
}, []);
// Show loading screen while stores are initializing
if (!isStoreReady) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
}}
>
<ActivityIndicator size="large" color="#fff" />
</View>
</GestureHandlerRootView>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ErrorBoundary
onError={(error, errorInfo) => {
// Hier könnte man Error-Logging an einen Service senden
console.error('App Error:', error, errorInfo);
}}
>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="author/[id]" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="list/[id]" options={{ headerShown: false }} />
<Stack.Screen
name="paywall"
options={{
headerShown: false,
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
</Stack>
</ErrorBoundary>
</GestureHandlerRootView>
);
}

View file

@ -11,380 +11,439 @@ import { GlassTabSelector } from '~/components/common/GlassTabSelector';
import * as Haptics from 'expo-haptics';
import { useTranslation } from 'react-i18next';
import Animated, {
FadeIn,
FadeInDown,
useAnimatedStyle,
useSharedValue,
withSpring
FadeIn,
FadeInDown,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import { useTheme } from '~/hooks/useTheme';
export default function AuthorDetailScreen() {
const { id } = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const likeScale = useSharedValue(1);
const {
authors,
quotes,
initializeStore,
isLoading,
toggleFavorite,
toggleAuthorFavorite,
isAuthorFavorite
} = useQuotesStore();
const [activeTab, setActiveTab] = useState<'quotes' | 'bio'>('quotes');
const { id } = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { colors } = useTheme();
const likeScale = useSharedValue(1);
const {
authors,
quotes,
initializeStore,
isLoading,
toggleFavorite,
toggleAuthorFavorite,
isAuthorFavorite,
} = useQuotesStore();
const [activeTab, setActiveTab] = useState<'quotes' | 'bio'>('quotes');
useEffect(() => {
initializeStore();
}, []);
useEffect(() => {
initializeStore();
}, []);
const author = authors?.find(a => a.id === id);
const authorQuotes = quotes?.filter(q => q.authorId === id) || [];
const isFavorite = author ? isAuthorFavorite(author.id) : false;
const tabs = [
{ key: 'quotes', label: t('authors.quotes'), count: authorQuotes.length },
{ key: 'bio', label: t('authors.biography') }
];
const author = authors?.find((a) => a.id === id);
const authorQuotes = quotes?.filter((q) => q.authorId === id) || [];
const isFavorite = author ? isAuthorFavorite(author.id) : false;
const likeAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: likeScale.value }]
}));
const tabs = [
{ key: 'quotes', label: t('authors.quotes'), count: authorQuotes.length },
{ key: 'bio', label: t('authors.biography') },
];
const handleFavoriteToggle = () => {
if (author) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
likeScale.value = withSpring(1.3, {}, () => {
likeScale.value = withSpring(1);
});
toggleAuthorFavorite(author.id);
}
};
const likeAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: likeScale.value }],
}));
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" color={isDarkMode ? '#ffffff' : '#000000'} />
</View>
</View>
);
}
const handleFavoriteToggle = () => {
if (author) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
likeScale.value = withSpring(1.3, {}, () => {
likeScale.value = withSpring(1);
});
toggleAuthorFavorite(author.id);
}
};
if (!author) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View className="flex-1 justify-center items-center">
<Text variant="body" color="secondary">{t('authors.notFound')}</Text>
<Pressable
onPress={() => router.back()}
className="mt-4 px-4 py-2 bg-blue-500 rounded-full"
>
<Text variant="body" color="primary">{t('common.back')}</Text>
</Pressable>
</View>
</View>
);
}
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" color={isDarkMode ? '#ffffff' : '#000000'} />
</View>
</View>
);
}
const getLifeYears = () => {
if (!author.lifespan) return null;
const birth = author.lifespan.birth?.substring(0, 4);
const death = author.lifespan.death?.substring(0, 4);
if (birth && death) {
const birthYear = parseInt(birth);
const deathYear = parseInt(death);
const age = deathYear - birthYear;
return `${birth} ${death} (${age} ${t('authors.years')})`;
}
if (birth) {
const birthYear = parseInt(birth);
const currentYear = new Date().getFullYear();
const age = currentYear - birthYear;
return `${t('authors.born')} ${birth} (${age} ${t('authors.yearsOld')})`;
}
return null;
};
if (!author) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View className="flex-1 justify-center items-center">
<Text variant="body" color="secondary">
{t('authors.notFound')}
</Text>
<Pressable
onPress={() => router.back()}
className="mt-4 px-4 py-2 bg-blue-500 rounded-full"
>
<Text variant="body" color="primary">
{t('common.back')}
</Text>
</Pressable>
</View>
</View>
);
}
return (
<>
<Stack.Screen
options={{
title: author.name,
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerBackTitle: 'Autoren',
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: 100, paddingBottom: 100 }}
>
{/* Author Info - with max width constraint */}
<Animated.View entering={FadeIn.duration(600)} className="pb-4 px-6">
<Animated.View entering={FadeInDown.delay(100).duration(600)} className="items-center">
<AuthorAvatar
name={author.name}
imageUrl={author.imageUrl}
size="large"
/>
<Text variant="title" color="primary" weight="bold" className="mt-4 text-center" numberOfLines={2}>
{author.name}
</Text>
{getLifeYears() && (
<Text variant="body" color="secondary" className="mt-1">
{getLifeYears()}
</Text>
)}
const getLifeYears = () => {
if (!author.lifespan) return null;
const birth = author.lifespan.birth?.substring(0, 4);
const death = author.lifespan.death?.substring(0, 4);
{/* Professions - with max width and proper wrapping */}
{author.profession && author.profession.length > 0 && (
<View className="flex-row flex-wrap justify-center mt-2.5 px-4" style={{ maxWidth: 300 }}>
{author.profession.slice(0, 2).map((prof, idx) => (
<View
key={idx}
className={`px-2.5 py-1 rounded-full mr-1.5 mb-1.5 ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
style={{ maxWidth: 100 }}
>
<Text
variant="caption"
color="secondary"
numberOfLines={1}
style={{ fontSize: 10 }}
>
{prof.length > 12 ? prof.substring(0, 12) + '...' : prof}
</Text>
</View>
))}
{author.profession.length > 2 && (
<View
className={`px-2.5 py-1 rounded-full mb-1.5 ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
>
<Text variant="caption" color="secondary" style={{ fontSize: 10 }}>
+{author.profession.length - 2}
</Text>
</View>
)}
</View>
)}
if (birth && death) {
const birthYear = parseInt(birth);
const deathYear = parseInt(death);
const age = deathYear - birthYear;
return `${birth} ${death} (${age} ${t('authors.years')})`;
}
if (birth) {
const birthYear = parseInt(birth);
const currentYear = new Date().getFullYear();
const age = currentYear - birthYear;
return `${t('authors.born')} ${birth} (${age} ${t('authors.yearsOld')})`;
}
return null;
};
{/* Favorite Button - compact and responsive */}
<View className="mt-3 mb-2 items-center">
<Pressable
onPress={handleFavoriteToggle}
className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} px-3.5 py-2 rounded-full`}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Animated.View style={likeAnimatedStyle}>
<Icon
name={isFavorite ? 'heart' : 'heart-outline'}
size={22}
color={isFavorite ? '#ef4444' : (isDarkMode ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.6)')}
/>
</Animated.View>
</Pressable>
</View>
return (
<>
<Stack.Screen
options={{
title: author.name,
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerBackTitle: 'Autoren',
}}
/>
{/* Stats - with proper spacing */}
<View className="flex-row mt-3">
<View className="items-center px-3">
<Text variant="bodyLarge" color="primary" weight="bold">
{authorQuotes.length}
</Text>
<Text variant="caption" color="secondary" style={{ fontSize: 11 }}>
{t('navigation.quotes')}
</Text>
</View>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
className="flex-1"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: 100, paddingBottom: 100 }}
>
{/* Author Info - with max width constraint */}
<Animated.View entering={FadeIn.duration(600)} className="pb-4 px-6">
<Animated.View entering={FadeInDown.delay(100).duration(600)} className="items-center">
<AuthorAvatar name={author.name} imageUrl={author.imageUrl} size="large" />
{author.nationality && (
<View className={`items-center px-3 border-l ${isDarkMode ? 'border-white/20' : 'border-black/20'}`}>
<Text variant="body" color="primary" numberOfLines={1} style={{ fontSize: 14 }}>
{Array.isArray(author.nationality)
? author.nationality[0]
: (typeof author.nationality === 'string' && author.nationality.length > 12
? author.nationality.substring(0, 12) + '...'
: author.nationality)}
</Text>
<Text variant="caption" color="secondary" style={{ fontSize: 11 }}>
Nationalität
</Text>
</View>
)}
</View>
</Animated.View>
</Animated.View>
<Text
variant="title"
color="primary"
weight="bold"
className="mt-4 text-center"
numberOfLines={2}
>
{author.name}
</Text>
{/* Tabs */}
<View className="mb-2">
<GlassTabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as 'quotes' | 'bio')}
animationDelay={200}
/>
</View>
{getLifeYears() && (
<Text variant="body" color="secondary" className="mt-1">
{getLifeYears()}
</Text>
)}
{/* Content */}
<Animated.View entering={FadeInDown.delay(300).duration(600)} className="pt-4">
{activeTab === 'quotes' ? (
<View>
{authorQuotes.length > 0 ? (
authorQuotes.map((quote, index) => (
<View key={quote.id} className="mb-5">
<QuoteCard
quote={quote}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {}}
showDate={true}
/>
</View>
))
) : (
<View className="py-12">
<Text variant="body" color="secondary" className="text-center">
{t('authors.noQuotes')}
</Text>
</View>
)}
</View>
) : (
<View className="px-6">
{/* Biography */}
{(author.biography?.long || author.biography?.short || author.biography?.sections) ? (
<View>
{/* Main Biography Text */}
{(author.biography.long || author.biography.short) && (
<View className={`p-3 rounded-2xl mb-6 ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}>
<Text variant="body" color="primary" className="leading-relaxed">
{author.biography.long || author.biography.short}
</Text>
</View>
)}
{/* Biography Sections */}
{author.biography.sections && (
<View className="mb-6">
{Object.entries(author.biography.sections).map(([key, section]: [string, any]) => (
<View key={key} className="mb-6">
<Text variant="bodyLarge" color="primary" weight="semibold" className="mb-3">
{section.title}
</Text>
<View className={`p-3 rounded-2xl ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}>
<Text variant="body" color="primary" className="leading-relaxed">
{section.content}
</Text>
</View>
</View>
))}
</View>
)}
{/* Key Achievements */}
{author.biography.keyAchievements && author.biography.keyAchievements.length > 0 && (
<View className="mb-6">
<Text variant="bodyLarge" color="primary" weight="semibold" className="mb-3">
{t('authors.keyAchievements', { defaultValue: 'Wichtige Errungenschaften' })}
</Text>
<View className={`p-3 rounded-2xl ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}>
{author.biography.keyAchievements.map((achievement: string, idx: number) => (
<View key={idx} className="flex-row mb-2">
<Text variant="body" color="secondary" className="mr-2"></Text>
<Text variant="body" color="primary" className="flex-1">
{achievement}
</Text>
</View>
))}
</View>
</View>
)}
{/* Famous Quote */}
{author.biography.famousQuote && (
<View className={`p-3 rounded-2xl mb-6 ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}>
<View className="flex-row mb-2">
<Icon name="quote" size={20} color={isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'} />
</View>
<Text variant="bodyLarge" color="primary" className="leading-relaxed italic">
"{author.biography.famousQuote}"
</Text>
</View>
)}
</View>
) : (
<View className="py-12">
<Text variant="body" color="secondary" className="text-center">
{t('authors.noBiography')}
</Text>
</View>
)}
{/* Professions - with max width and proper wrapping */}
{author.profession && author.profession.length > 0 && (
<View
className="flex-row flex-wrap justify-center mt-2.5 px-4"
style={{ maxWidth: 300 }}
>
{author.profession.slice(0, 2).map((prof, idx) => (
<View
key={idx}
className={`px-2.5 py-1 rounded-full mr-1.5 mb-1.5 ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
style={{ maxWidth: 100 }}
>
<Text
variant="caption"
color="secondary"
numberOfLines={1}
style={{ fontSize: 10 }}
>
{prof.length > 12 ? prof.substring(0, 12) + '...' : prof}
</Text>
</View>
))}
{author.profession.length > 2 && (
<View
className={`px-2.5 py-1 rounded-full mb-1.5 ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
>
<Text variant="caption" color="secondary" style={{ fontSize: 10 }}>
+{author.profession.length - 2}
</Text>
</View>
)}
</View>
)}
{/* Links Section */}
{author.links && (author.links.wikipedia || author.links.website) && (
<View>
<Text variant="bodyLarge" color="primary" weight="semibold" className="mb-3">
{t('authors.moreInfo')}
</Text>
{author.links.wikipedia && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Open Wikipedia link
}}
className={`flex-row items-center py-3 px-4 rounded-xl mb-2 ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
>
<Icon name="globe-outline" size={20} color={isDarkMode ? 'white' : 'black'} />
<Text variant="body" color="primary" className="ml-3">
{t('authors.wikipedia')}
</Text>
</Pressable>
)}
{author.links?.website && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Open website
}}
className={`flex-row items-center py-3 px-4 rounded-xl ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
>
<Icon name="link-outline" size={20} color={isDarkMode ? 'white' : 'black'} />
<Text variant="body" color="primary" className="ml-3">
{t('authors.website')}
</Text>
</Pressable>
)}
</View>
)}
</View>
)}
</Animated.View>
</ScrollView>
</View>
</>
);
}
{/* Favorite Button - compact and responsive */}
<View className="mt-3 mb-2 items-center">
<Pressable
onPress={handleFavoriteToggle}
className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} px-3.5 py-2 rounded-full`}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Animated.View style={likeAnimatedStyle}>
<Icon
name={isFavorite ? 'heart' : 'heart-outline'}
size={22}
color={
isFavorite
? '#ef4444'
: isDarkMode
? 'rgba(255,255,255,0.8)'
: 'rgba(0,0,0,0.6)'
}
/>
</Animated.View>
</Pressable>
</View>
{/* Stats - with proper spacing */}
<View className="flex-row mt-3">
<View className="items-center px-3">
<Text variant="bodyLarge" color="primary" weight="bold">
{authorQuotes.length}
</Text>
<Text variant="caption" color="secondary" style={{ fontSize: 11 }}>
{t('navigation.quotes')}
</Text>
</View>
{author.nationality && (
<View
className={`items-center px-3 border-l ${isDarkMode ? 'border-white/20' : 'border-black/20'}`}
>
<Text variant="body" color="primary" numberOfLines={1} style={{ fontSize: 14 }}>
{Array.isArray(author.nationality)
? author.nationality[0]
: typeof author.nationality === 'string' && author.nationality.length > 12
? author.nationality.substring(0, 12) + '...'
: author.nationality}
</Text>
<Text variant="caption" color="secondary" style={{ fontSize: 11 }}>
Nationalität
</Text>
</View>
)}
</View>
</Animated.View>
</Animated.View>
{/* Tabs */}
<View className="mb-2">
<GlassTabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as 'quotes' | 'bio')}
animationDelay={200}
/>
</View>
{/* Content */}
<Animated.View entering={FadeInDown.delay(300).duration(600)} className="pt-4">
{activeTab === 'quotes' ? (
<View>
{authorQuotes.length > 0 ? (
authorQuotes.map((quote, index) => (
<View key={quote.id} className="mb-5">
<QuoteCard
quote={quote}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {}}
showDate={true}
/>
</View>
))
) : (
<View className="py-12">
<Text variant="body" color="secondary" className="text-center">
{t('authors.noQuotes')}
</Text>
</View>
)}
</View>
) : (
<View className="px-6">
{/* Biography */}
{author.biography?.long || author.biography?.short || author.biography?.sections ? (
<View>
{/* Main Biography Text */}
{(author.biography.long || author.biography.short) && (
<View
className={`p-3 rounded-2xl mb-6 ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}
>
<Text variant="body" color="primary" className="leading-relaxed">
{author.biography.long || author.biography.short}
</Text>
</View>
)}
{/* Biography Sections */}
{author.biography.sections && (
<View className="mb-6">
{Object.entries(author.biography.sections).map(
([key, section]: [string, any]) => (
<View key={key} className="mb-6">
<Text
variant="bodyLarge"
color="primary"
weight="semibold"
className="mb-3"
>
{section.title}
</Text>
<View
className={`p-3 rounded-2xl ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}
>
<Text variant="body" color="primary" className="leading-relaxed">
{section.content}
</Text>
</View>
</View>
)
)}
</View>
)}
{/* Key Achievements */}
{author.biography.keyAchievements &&
author.biography.keyAchievements.length > 0 && (
<View className="mb-6">
<Text
variant="bodyLarge"
color="primary"
weight="semibold"
className="mb-3"
>
{t('authors.keyAchievements', {
defaultValue: 'Wichtige Errungenschaften',
})}
</Text>
<View
className={`p-3 rounded-2xl ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}
>
{author.biography.keyAchievements.map(
(achievement: string, idx: number) => (
<View key={idx} className="flex-row mb-2">
<Text variant="body" color="secondary" className="mr-2">
</Text>
<Text variant="body" color="primary" className="flex-1">
{achievement}
</Text>
</View>
)
)}
</View>
</View>
)}
{/* Famous Quote */}
{author.biography.famousQuote && (
<View
className={`p-3 rounded-2xl mb-6 ${isDarkMode ? 'bg-white/5' : 'bg-black/5'}`}
>
<View className="flex-row mb-2">
<Icon
name="quote"
size={20}
color={isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
/>
</View>
<Text
variant="bodyLarge"
color="primary"
className="leading-relaxed italic"
>
"{author.biography.famousQuote}"
</Text>
</View>
)}
</View>
) : (
<View className="py-12">
<Text variant="body" color="secondary" className="text-center">
{t('authors.noBiography')}
</Text>
</View>
)}
{/* Links Section */}
{author.links && (author.links.wikipedia || author.links.website) && (
<View>
<Text variant="bodyLarge" color="primary" weight="semibold" className="mb-3">
{t('authors.moreInfo')}
</Text>
{author.links.wikipedia && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Open Wikipedia link
}}
className={`flex-row items-center py-3 px-4 rounded-xl mb-2 ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
>
<Icon
name="globe-outline"
size={20}
color={isDarkMode ? 'white' : 'black'}
/>
<Text variant="body" color="primary" className="ml-3">
{t('authors.wikipedia')}
</Text>
</Pressable>
)}
{author.links?.website && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Open website
}}
className={`flex-row items-center py-3 px-4 rounded-xl ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
>
<Icon
name="link-outline"
size={20}
color={isDarkMode ? 'white' : 'black'}
/>
<Text variant="body" color="primary" className="ml-3">
{t('authors.website')}
</Text>
</Pressable>
)}
</View>
)}
</View>
)}
</Animated.View>
</ScrollView>
</View>
</>
);
}

View file

@ -1,14 +1,14 @@
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import {
View,
Text,
FlatList,
Dimensions,
Pressable,
Modal,
Alert,
ScrollView,
TouchableOpacity
import {
View,
Text,
FlatList,
Dimensions,
Pressable,
Modal,
Alert,
ScrollView,
TouchableOpacity,
} from 'react-native';
import { useQuotesStore, EnhancedQuote } from '~/store/quotesStore';
import { useListStore, List } from '~/store/listStore';
@ -18,17 +18,17 @@ import * as Haptics from 'expo-haptics';
import { Host, Picker } from '@expo/ui/swift-ui';
import { useIsDarkMode } from '~/store/settingsStore';
import QuoteCard from '~/components/QuoteCard';
import Animated, {
FadeInRight,
FadeInDown,
FadeOutUp,
useAnimatedScrollHandler,
useSharedValue
import Animated, {
FadeInRight,
FadeInDown,
FadeOutUp,
useAnimatedScrollHandler,
useSharedValue,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import DraggableFlatList, {
ScaleDecorator,
RenderItemParams
ScaleDecorator,
RenderItemParams,
} from 'react-native-draggable-flatlist';
import { LIST_ITEM_CLASSES, LIST_CONTAINER_PADDING } from '~/constants/layout';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
@ -38,326 +38,324 @@ const { width: screenWidth } = Dimensions.get('window');
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
export default function ListDetail() {
const router = useRouter();
const { id } = useLocalSearchParams();
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const [isEditing, setIsEditing] = useState(false);
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
const scrollY = useSharedValue(0);
const {
quotes,
toggleFavorite,
initializeStore
} = useQuotesStore();
const router = useRouter();
const { id } = useLocalSearchParams();
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const [isEditing, setIsEditing] = useState(false);
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
const scrollY = useSharedValue(0);
const {
getList,
getListQuotes,
removeQuoteFromList,
reorderListItems,
updateList,
sortList
} = useListStore();
const { quotes, toggleFavorite, initializeStore } = useQuotesStore();
const [list, setList] = useState<List | undefined>(undefined);
const [listQuotes, setListQuotes] = useState<EnhancedQuote[]>([]);
const { getList, getListQuotes, removeQuoteFromList, reorderListItems, updateList, sortList } =
useListStore();
useEffect(() => {
initializeStore();
}, []);
const [list, setList] = useState<List | undefined>(undefined);
const [listQuotes, setListQuotes] = useState<EnhancedQuote[]>([]);
useEffect(() => {
if (id && quotes.length > 0) {
const pl = getList(id as string);
setList(pl);
if (pl) {
const pQuotes = getListQuotes(pl.id, quotes);
setListQuotes(pQuotes);
}
}
}, [id, quotes, getList, getListQuotes]);
useEffect(() => {
initializeStore();
}, []);
useEffect(() => {
if (id && quotes.length > 0) {
const pl = getList(id as string);
setList(pl);
if (pl) {
const pQuotes = getListQuotes(pl.id, quotes);
setListQuotes(pQuotes);
}
}
}, [id, quotes, getList, getListQuotes]);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const handleRemoveQuote = (quoteId: string) => {
Alert.alert(
t('lists.removeQuote'),
t('lists.removeQuoteConfirm'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => {
if (list) {
removeQuoteFromList(list.id, quoteId);
const updatedQuotes = listQuotes.filter(q => q.id !== quoteId);
setListQuotes(updatedQuotes);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
}
}
]
);
};
const handleRemoveQuote = (quoteId: string) => {
Alert.alert(t('lists.removeQuote'), t('lists.removeQuoteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => {
if (list) {
removeQuoteFromList(list.id, quoteId);
const updatedQuotes = listQuotes.filter((q) => q.id !== quoteId);
setListQuotes(updatedQuotes);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
},
},
]);
};
const handleSortChange = (sortMode: List['sortMode']) => {
if (!list) return;
sortList(list.id, sortMode);
updateList(list.id, { sortMode });
// Reload quotes with new sort
const pl = getList(list.id);
if (pl) {
const pQuotes = getListQuotes(pl.id, quotes);
setListQuotes(pQuotes);
setList(pl);
}
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleSortChange = (sortMode: List['sortMode']) => {
if (!list) return;
const handleDragEnd = ({ data }) => {
if (!list) return;
// Update positions based on new order
data.forEach((quote, index) => {
const itemIndex = list.items.findIndex(item => item.quoteId === quote.id);
if (itemIndex !== -1) {
reorderListItems(list.id, itemIndex, index);
}
});
setListQuotes(data);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
sortList(list.id, sortMode);
updateList(list.id, { sortMode });
// Reload quotes with new sort
const pl = getList(list.id);
if (pl) {
const pQuotes = getListQuotes(pl.id, quotes);
setListQuotes(pQuotes);
setList(pl);
}
const renderQuote = ({ item, index }) => {
if (viewMode === 'card') {
const CARD_HEIGHT = Dimensions.get('window').height - 250;
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
scrollY={scrollY}
cardHeight={CARD_HEIGHT}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
} else if (item?.author?.id) {
router.push(`/author/${item.author.id}`);
}
}}
onDelete={isEditing ? () => handleRemoveQuote(item.id) : undefined}
/>
</View>
);
}
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
} else if (item?.author?.id) {
router.push(`/author/${item.author.id}`);
}
}}
onDelete={isEditing ? () => handleRemoveQuote(item.id) : undefined}
/>
</View>
);
};
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const renderDraggableQuote = ({ item, drag, isActive }: RenderItemParams<EnhancedQuote>) => {
return (
<ScaleDecorator>
<Pressable
onLongPress={drag}
disabled={!isEditing}
className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4 ${LIST_ITEM_CLASSES.wrapper} ${
isActive ? 'opacity-80' : ''
}`}
>
<View className="flex-row items-center">
{isEditing && (
<Icon
name="reorder-three"
size={24}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
)}
<View className="flex-1 ml-3">
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-base`} numberOfLines={2}>
{item.text}
</Text>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-sm mt-1`}>
{item.author?.name}
</Text>
</View>
{isEditing && (
<Pressable
onPress={() => handleRemoveQuote(item.id)}
className="ml-3"
>
<Icon
name="close-circle"
size={24}
color={isDarkMode ? 'rgba(255,0,0,0.6)' : 'rgba(255,0,0,0.6)'}
/>
</Pressable>
)}
</View>
</Pressable>
</ScaleDecorator>
);
};
const handleDragEnd = ({ data }) => {
if (!list) return;
if (!list) {
return (
<View className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'} justify-center items-center`}>
<Text className={isDarkMode ? 'text-white' : 'text-black'}>Liste nicht gefunden</Text>
</View>
);
}
// Update positions based on new order
data.forEach((quote, index) => {
const itemIndex = list.items.findIndex((item) => item.quoteId === quote.id);
if (itemIndex !== -1) {
reorderListItems(list.id, itemIndex, index);
}
});
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack.Screen
options={{
title: list.name,
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerBackTitle: t('lists.lists'),
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'list' ? 'grid-outline' : 'list-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setIsEditing(!isEditing)}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={isEditing ? "checkmark-circle" : "create-outline"}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
</View>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'}`}>
setListQuotes(data);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};
const renderQuote = ({ item, index }) => {
if (viewMode === 'card') {
const CARD_HEIGHT = Dimensions.get('window').height - 250;
return (
<View style={{ position: 'relative', width: '100%' }}>
<QuoteCard
quote={item}
variant="vertical"
index={index}
scrollY={scrollY}
cardHeight={CARD_HEIGHT}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
} else if (item?.author?.id) {
router.push(`/author/${item.author.id}`);
}
}}
onDelete={isEditing ? () => handleRemoveQuote(item.id) : undefined}
/>
</View>
);
}
{/* Content */}
{listQuotes.length === 0 ? (
<View className="flex-1 justify-center items-center px-6" style={{ paddingTop: 100 }}>
<Icon
name="book-outline"
size={64}
color={isDarkMode ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.2)"}
/>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-lg mt-4 text-center font-semibold`}>
{t('lists.noQuotesInList')}
</Text>
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm mt-2 text-center`}>
{t('lists.emptyListHint')}
</Text>
</View>
) : isEditing && list.sortMode === 'manual' ? (
<DraggableFlatList
data={listQuotes}
onDragEnd={handleDragEnd}
keyExtractor={(item) => item.id}
renderItem={renderDraggableQuote}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: LIST_CONTAINER_PADDING.bottom, paddingTop: LIST_CONTAINER_PADDING.top }}
/>
) : (
<AnimatedFlatList
data={listQuotes}
renderItem={renderQuote}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{
paddingTop: LIST_CONTAINER_PADDING.top,
paddingBottom: viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom : 100
}}
pagingEnabled={viewMode === 'card'}
snapToInterval={viewMode === 'card' ? Dimensions.get('window').height - 250 : undefined}
snapToAlignment={viewMode === 'card' ? "start" : undefined}
decelerationRate={viewMode === 'card' ? "fast" : "normal"}
/>
)}
return (
<View className="mb-5">
<QuoteCard
quote={item}
onToggleFavorite={toggleFavorite}
onAuthorPress={() => {
if (item?.authorId) {
router.push(`/author/${item.authorId}`);
} else if (item?.author?.id) {
router.push(`/author/${item.author.id}`);
}
}}
onDelete={isEditing ? () => handleRemoveQuote(item.id) : undefined}
/>
</View>
);
};
{/* Segmented Control at bottom */}
{!isEditing && list.sortMode && (
<View className="absolute bottom-0 left-0 right-0 pb-28 px-4">
<Host matchContents style={{ width: '100%' }}>
<Picker
options={['Manuell', 'A-Z', 'Autor', 'Datum', 'Zufällig']}
selectedIndex={['manual', 'alphabetical', 'author', 'date', 'random'].indexOf(list.sortMode)}
onOptionSelected={({ nativeEvent: { index } }) => {
const modes: List['sortMode'][] = ['manual', 'alphabetical', 'author', 'date', 'random'];
handleSortChange(modes[index]);
}}
variant="segmented"
/>
</Host>
</View>
)}
const renderDraggableQuote = ({ item, drag, isActive }: RenderItemParams<EnhancedQuote>) => {
return (
<ScaleDecorator>
<Pressable
onLongPress={drag}
disabled={!isEditing}
className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4 ${LIST_ITEM_CLASSES.wrapper} ${
isActive ? 'opacity-80' : ''
}`}
>
<View className="flex-row items-center">
{isEditing && (
<Icon
name="reorder-three"
size={24}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
)}
<View className="flex-1 ml-3">
<Text
className={`${isDarkMode ? 'text-white' : 'text-black'} text-base`}
numberOfLines={2}
>
{item.text}
</Text>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-sm mt-1`}>
{item.author?.name}
</Text>
</View>
{isEditing && (
<Pressable onPress={() => handleRemoveQuote(item.id)} className="ml-3">
<Icon
name="close-circle"
size={24}
color={isDarkMode ? 'rgba(255,0,0,0.6)' : 'rgba(255,0,0,0.6)'}
/>
</Pressable>
)}
</View>
</Pressable>
</ScaleDecorator>
);
};
</View>
</GestureHandlerRootView>
);
}
if (!list) {
return (
<View
className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'} justify-center items-center`}
>
<Text className={isDarkMode ? 'text-white' : 'text-black'}>Liste nicht gefunden</Text>
</View>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack.Screen
options={{
title: list.name,
headerShown: true,
headerTransparent: true,
headerBlurEffect: isDarkMode ? 'dark' : 'light',
headerStyle: {
backgroundColor: 'transparent',
},
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
headerShadowVisible: false,
headerBackTitle: t('lists.lists'),
headerRight: () => (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setViewMode(viewMode === 'card' ? 'list' : 'card');
}}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={viewMode === 'list' ? 'grid-outline' : 'list-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setIsEditing(!isEditing)}
style={{
justifyContent: 'center',
alignItems: 'center',
width: 44,
height: 44,
marginTop: -4,
}}
>
<Icon
name={isEditing ? 'checkmark-circle' : 'create-outline'}
size={24}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</TouchableOpacity>
</View>
),
headerRightContainerStyle: {
paddingRight: 16,
},
}}
/>
<View className={`flex-1 ${isDarkMode ? 'bg-black' : 'bg-white'}`}>
{/* Content */}
{listQuotes.length === 0 ? (
<View className="flex-1 justify-center items-center px-6" style={{ paddingTop: 100 }}>
<Icon
name="book-outline"
size={64}
color={isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}
/>
<Text
className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-lg mt-4 text-center font-semibold`}
>
{t('lists.noQuotesInList')}
</Text>
<Text
className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm mt-2 text-center`}
>
{t('lists.emptyListHint')}
</Text>
</View>
) : isEditing && list.sortMode === 'manual' ? (
<DraggableFlatList
data={listQuotes}
onDragEnd={handleDragEnd}
keyExtractor={(item) => item.id}
renderItem={renderDraggableQuote}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingBottom: LIST_CONTAINER_PADDING.bottom,
paddingTop: LIST_CONTAINER_PADDING.top,
}}
/>
) : (
<AnimatedFlatList
data={listQuotes}
renderItem={renderQuote}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
contentContainerStyle={{
paddingTop: LIST_CONTAINER_PADDING.top,
paddingBottom: viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom : 100,
}}
pagingEnabled={viewMode === 'card'}
snapToInterval={viewMode === 'card' ? Dimensions.get('window').height - 250 : undefined}
snapToAlignment={viewMode === 'card' ? 'start' : undefined}
decelerationRate={viewMode === 'card' ? 'fast' : 'normal'}
/>
)}
{/* Segmented Control at bottom */}
{!isEditing && list.sortMode && (
<View className="absolute bottom-0 left-0 right-0 pb-28 px-4">
<Host matchContents style={{ width: '100%' }}>
<Picker
options={['Manuell', 'A-Z', 'Autor', 'Datum', 'Zufällig']}
selectedIndex={['manual', 'alphabetical', 'author', 'date', 'random'].indexOf(
list.sortMode
)}
onOptionSelected={({ nativeEvent: { index } }) => {
const modes: List['sortMode'][] = [
'manual',
'alphabetical',
'author',
'date',
'random',
];
handleSortChange(modes[index]);
}}
variant="segmented"
/>
</Host>
</View>
)}
</View>
</GestureHandlerRootView>
);
}

View file

@ -2,5 +2,5 @@ import React from 'react';
import AppleStyleOnboarding from '~/components/onboarding/AppleStyleOnboarding';
export default function OnboardingScreen() {
return <AppleStyleOnboarding />;
}
return <AppleStyleOnboarding />;
}

View file

@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Alert,
Platform,
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
Alert,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
@ -17,310 +17,292 @@ import usePremiumStore from '~/store/premiumStore';
// Remove direct import since we handle it conditionally in RevenueCat service
export default function PaywallScreen() {
const [packages, setPackages] = useState<any[]>([]);
const [selectedPackage, setSelectedPackage] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPurchasing, setIsPurchasing] = useState(false);
const { setPremium } = usePremiumStore();
const [packages, setPackages] = useState<any[]>([]);
const [selectedPackage, setSelectedPackage] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPurchasing, setIsPurchasing] = useState(false);
const { setPremium } = usePremiumStore();
useEffect(() => {
loadOfferings();
}, []);
useEffect(() => {
loadOfferings();
}, []);
const loadOfferings = async () => {
setIsLoading(true);
console.log('[Paywall] Loading offerings...');
try {
const offerings = await RevenueCat.getOfferings();
console.log('[Paywall] Offerings received:', offerings);
const loadOfferings = async () => {
setIsLoading(true);
console.log('[Paywall] Loading offerings...');
try {
const offerings = await RevenueCat.getOfferings();
console.log('[Paywall] Offerings received:', offerings);
if (offerings && offerings.availablePackages) {
console.log('[Paywall] Available packages:', offerings.availablePackages.length);
offerings.availablePackages.forEach((pkg: any) => {
console.log(`[Paywall] Package: ${pkg.identifier}, Price: ${pkg.product?.priceString}`);
});
if (offerings && offerings.availablePackages) {
console.log('[Paywall] Available packages:', offerings.availablePackages.length);
offerings.availablePackages.forEach((pkg: any) => {
console.log(`[Paywall] Package: ${pkg.identifier}, Price: ${pkg.product?.priceString}`);
});
setPackages(offerings.availablePackages);
// Standardmäßig Yearly auswählen (meist in der Mitte)
const yearlyPackage = offerings.availablePackages.find((p: any) =>
p.identifier.includes('yearly') || p.packageType === 'ANNUAL'
);
setSelectedPackage(yearlyPackage || offerings.availablePackages[0]);
console.log('[Paywall] Selected package:', yearlyPackage?.identifier || offerings.availablePackages[0]?.identifier);
} else {
console.log('[Paywall] No packages found in offerings');
Alert.alert('Fehler', 'Keine Abos verfügbar. Bitte versuche es später erneut.');
}
} catch (error) {
console.error('[Paywall] Error loading offerings:', error);
Alert.alert('Fehler', 'Konnte Abos nicht laden. Bitte versuche es später erneut.');
} finally {
setIsLoading(false);
}
};
setPackages(offerings.availablePackages);
// Standardmäßig Yearly auswählen (meist in der Mitte)
const yearlyPackage = offerings.availablePackages.find(
(p: any) => p.identifier.includes('yearly') || p.packageType === 'ANNUAL'
);
setSelectedPackage(yearlyPackage || offerings.availablePackages[0]);
console.log(
'[Paywall] Selected package:',
yearlyPackage?.identifier || offerings.availablePackages[0]?.identifier
);
} else {
console.log('[Paywall] No packages found in offerings');
Alert.alert('Fehler', 'Keine Abos verfügbar. Bitte versuche es später erneut.');
}
} catch (error) {
console.error('[Paywall] Error loading offerings:', error);
Alert.alert('Fehler', 'Konnte Abos nicht laden. Bitte versuche es später erneut.');
} finally {
setIsLoading(false);
}
};
const handlePurchase = async () => {
if (!selectedPackage) {
console.log('[Paywall] No package selected');
return;
}
const handlePurchase = async () => {
if (!selectedPackage) {
console.log('[Paywall] No package selected');
return;
}
console.log('[Paywall] Starting purchase for:', selectedPackage.identifier);
setIsPurchasing(true);
try {
const success = await RevenueCat.purchasePackage(selectedPackage);
console.log('[Paywall] Purchase result:', success);
console.log('[Paywall] Starting purchase for:', selectedPackage.identifier);
setIsPurchasing(true);
try {
const success = await RevenueCat.purchasePackage(selectedPackage);
console.log('[Paywall] Purchase result:', success);
if (success) {
setPremium(true);
Alert.alert(
'Erfolgreich!',
'Willkommen bei Zitare Premium! 🎉',
[{ text: 'OK', onPress: () => router.back() }]
);
} else {
console.log('[Paywall] Purchase failed or was cancelled');
Alert.alert('Abgebrochen', 'Der Kauf wurde nicht abgeschlossen.');
}
} catch (error: any) {
console.error('[Paywall] Purchase error:', error);
if (!error.userCancelled) {
Alert.alert(
'Fehler',
`Kauf konnte nicht abgeschlossen werden.\n${error.message || 'Unbekannter Fehler'}`
);
}
} finally {
setIsPurchasing(false);
}
};
if (success) {
setPremium(true);
Alert.alert('Erfolgreich!', 'Willkommen bei Zitare Premium! 🎉', [
{ text: 'OK', onPress: () => router.back() },
]);
} else {
console.log('[Paywall] Purchase failed or was cancelled');
Alert.alert('Abgebrochen', 'Der Kauf wurde nicht abgeschlossen.');
}
} catch (error: any) {
console.error('[Paywall] Purchase error:', error);
if (!error.userCancelled) {
Alert.alert(
'Fehler',
`Kauf konnte nicht abgeschlossen werden.\n${error.message || 'Unbekannter Fehler'}`
);
}
} finally {
setIsPurchasing(false);
}
};
const handleRestore = async () => {
setIsPurchasing(true);
try {
const success = await RevenueCat.restorePurchases();
if (success) {
setPremium(true);
Alert.alert(
'Wiederhergestellt!',
'Deine Premium-Mitgliedschaft wurde wiederhergestellt.',
[{ text: 'OK', onPress: () => router.back() }]
);
} else {
Alert.alert('Keine Käufe gefunden', 'Es wurden keine früheren Käufe gefunden.');
}
} catch (error) {
Alert.alert('Fehler', 'Wiederherstellung fehlgeschlagen.');
} finally {
setIsPurchasing(false);
}
};
const handleRestore = async () => {
setIsPurchasing(true);
try {
const success = await RevenueCat.restorePurchases();
if (success) {
setPremium(true);
Alert.alert('Wiederhergestellt!', 'Deine Premium-Mitgliedschaft wurde wiederhergestellt.', [
{ text: 'OK', onPress: () => router.back() },
]);
} else {
Alert.alert('Keine Käufe gefunden', 'Es wurden keine früheren Käufe gefunden.');
}
} catch (error) {
Alert.alert('Fehler', 'Wiederherstellung fehlgeschlagen.');
} finally {
setIsPurchasing(false);
}
};
const getPackageDetails = (pkg: any) => {
const identifier = pkg.identifier.toLowerCase();
const product = pkg.product;
if (identifier.includes('lifetime')) {
return {
title: 'Lifetime',
subtitle: 'Einmalig, für immer',
price: product.priceString,
badge: 'BEST VALUE',
gradient: ['#FFD700', '#FFA500'],
};
} else if (identifier.includes('yearly') || pkg.packageType === 'ANNUAL') {
const monthlyPrice = (product.price / 12).toFixed(2);
return {
title: 'Jährlich',
subtitle: `Nur ${monthlyPrice}€/Monat`,
price: product.priceString + '/Jahr',
badge: 'SPARE 33%',
gradient: ['#8B5CF6', '#6366F1'],
};
} else {
return {
title: 'Monatlich',
subtitle: 'Flexibel kündbar',
price: product.priceString + '/Monat',
badge: null,
gradient: ['#10B981', '#059669'],
};
}
};
const getPackageDetails = (pkg: any) => {
const identifier = pkg.identifier.toLowerCase();
const product = pkg.product;
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-gray-50">
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#6366F1" />
</View>
</SafeAreaView>
);
}
if (identifier.includes('lifetime')) {
return {
title: 'Lifetime',
subtitle: 'Einmalig, für immer',
price: product.priceString,
badge: 'BEST VALUE',
gradient: ['#FFD700', '#FFA500'],
};
} else if (identifier.includes('yearly') || pkg.packageType === 'ANNUAL') {
const monthlyPrice = (product.price / 12).toFixed(2);
return {
title: 'Jährlich',
subtitle: `Nur ${monthlyPrice}€/Monat`,
price: product.priceString + '/Jahr',
badge: 'SPARE 33%',
gradient: ['#8B5CF6', '#6366F1'],
};
} else {
return {
title: 'Monatlich',
subtitle: 'Flexibel kündbar',
price: product.priceString + '/Monat',
badge: null,
gradient: ['#10B981', '#059669'],
};
}
};
return (
<SafeAreaView className="flex-1 bg-white">
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{/* Header */}
<View className="px-6 pt-4 pb-2">
<TouchableOpacity
onPress={() => router.back()}
className="self-start p-2 -ml-2"
>
<Ionicons name="close" size={28} color="#000" />
</TouchableOpacity>
</View>
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-gray-50">
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#6366F1" />
</View>
</SafeAreaView>
);
}
{/* Title */}
<View className="px-6 pb-6">
<Text className="text-3xl font-bold text-center mb-2">
Zitare Premium
</Text>
<Text className="text-lg text-gray-600 text-center">
Unbegrenzte Inspiration wartet
</Text>
</View>
return (
<SafeAreaView className="flex-1 bg-white">
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{/* Header */}
<View className="px-6 pt-4 pb-2">
<TouchableOpacity onPress={() => router.back()} className="self-start p-2 -ml-2">
<Ionicons name="close" size={28} color="#000" />
</TouchableOpacity>
</View>
{/* Features */}
<View className="px-6 pb-4">
<FeatureRow icon="heart" text="Unlimited Favoriten" subtext="statt 5/Tag" />
<FeatureRow icon="folder" text="Unlimited Sammlungen" subtext="statt 1/Woche" />
<FeatureRow icon="color-palette" text="50+ Premium Themes" />
<FeatureRow icon="flash" text="Keine Wartezeiten" />
<FeatureRow icon="share-social" text="Premium Sharing" />
<FeatureRow icon="stats-chart" text="Persönliche Statistiken" />
</View>
{/* Title */}
<View className="px-6 pb-6">
<Text className="text-3xl font-bold text-center mb-2">Zitare Premium</Text>
<Text className="text-lg text-gray-600 text-center">Unbegrenzte Inspiration wartet</Text>
</View>
{/* Price Options */}
<View className="px-6 pb-4">
{packages.map((pkg, index) => {
const details = getPackageDetails(pkg);
const isSelected = selectedPackage?.identifier === pkg.identifier;
{/* Features */}
<View className="px-6 pb-4">
<FeatureRow icon="heart" text="Unlimited Favoriten" subtext="statt 5/Tag" />
<FeatureRow icon="folder" text="Unlimited Sammlungen" subtext="statt 1/Woche" />
<FeatureRow icon="color-palette" text="50+ Premium Themes" />
<FeatureRow icon="flash" text="Keine Wartezeiten" />
<FeatureRow icon="share-social" text="Premium Sharing" />
<FeatureRow icon="stats-chart" text="Persönliche Statistiken" />
</View>
return (
<TouchableOpacity
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
className={`mb-2.5 rounded-2xl overflow-hidden ${
isSelected ? 'border-2 border-indigo-500' : 'border border-gray-200'
}`}
>
<LinearGradient
colors={isSelected ? details.gradient : ['#FFFFFF', '#FFFFFF']}
className="p-4"
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<View className="flex-row items-center">
<Text className={`text-lg font-semibold ${
isSelected ? 'text-white' : 'text-gray-900'
}`}>
{details.title}
</Text>
{details.badge && (
<View className={`ml-2 px-2 py-1 rounded-full ${
isSelected ? 'bg-white/20' : 'bg-indigo-100'
}`}>
<Text className={`text-xs font-bold ${
isSelected ? 'text-white' : 'text-indigo-600'
}`}>
{details.badge}
</Text>
</View>
)}
</View>
<Text className={`text-sm mt-1 ${
isSelected ? 'text-white/80' : 'text-gray-500'
}`}>
{details.subtitle}
</Text>
</View>
<View className="items-end">
<Text className={`text-xl font-bold ${
isSelected ? 'text-white' : 'text-gray-900'
}`}>
{details.price}
</Text>
</View>
<View className="ml-3">
<View className={`w-6 h-6 rounded-full border-2 ${
isSelected
? 'border-white bg-white'
: 'border-gray-300'
}`}>
{isSelected && (
<View className="flex-1 m-1 rounded-full bg-indigo-500" />
)}
</View>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
);
})}
</View>
{/* Price Options */}
<View className="px-6 pb-4">
{packages.map((pkg, index) => {
const details = getPackageDetails(pkg);
const isSelected = selectedPackage?.identifier === pkg.identifier;
{/* Purchase Button */}
<View className="px-6 pb-4">
<TouchableOpacity
onPress={handlePurchase}
disabled={isPurchasing || !selectedPackage}
className="bg-indigo-600 rounded-2xl py-4 shadow-lg"
style={{ opacity: isPurchasing ? 0.5 : 1 }}
>
{isPurchasing ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-white text-center text-lg font-semibold">
Weiter mit Premium
</Text>
)}
</TouchableOpacity>
</View>
return (
<TouchableOpacity
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
className={`mb-2.5 rounded-2xl overflow-hidden ${
isSelected ? 'border-2 border-indigo-500' : 'border border-gray-200'
}`}
>
<LinearGradient
colors={isSelected ? details.gradient : ['#FFFFFF', '#FFFFFF']}
className="p-4"
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<View className="flex-row items-center">
<Text
className={`text-lg font-semibold ${
isSelected ? 'text-white' : 'text-gray-900'
}`}
>
{details.title}
</Text>
{details.badge && (
<View
className={`ml-2 px-2 py-1 rounded-full ${
isSelected ? 'bg-white/20' : 'bg-indigo-100'
}`}
>
<Text
className={`text-xs font-bold ${
isSelected ? 'text-white' : 'text-indigo-600'
}`}
>
{details.badge}
</Text>
</View>
)}
</View>
<Text
className={`text-sm mt-1 ${isSelected ? 'text-white/80' : 'text-gray-500'}`}
>
{details.subtitle}
</Text>
</View>
<View className="items-end">
<Text
className={`text-xl font-bold ${
isSelected ? 'text-white' : 'text-gray-900'
}`}
>
{details.price}
</Text>
</View>
<View className="ml-3">
<View
className={`w-6 h-6 rounded-full border-2 ${
isSelected ? 'border-white bg-white' : 'border-gray-300'
}`}
>
{isSelected && <View className="flex-1 m-1 rounded-full bg-indigo-500" />}
</View>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
);
})}
</View>
{/* Restore & Terms */}
<View className="px-6 pb-6">
<TouchableOpacity
onPress={handleRestore}
disabled={isPurchasing}
className="py-2"
>
<Text className="text-center text-indigo-600 font-medium">
Käufe wiederherstellen
</Text>
</TouchableOpacity>
<Text className="text-center text-xs text-gray-400 mt-4">
Mit dem Kauf stimmst du unseren AGB und Datenschutzbestimmungen zu.
{'\n'}Abos verlängern sich automatisch, können aber jederzeit gekündigt werden.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
{/* Purchase Button */}
<View className="px-6 pb-4">
<TouchableOpacity
onPress={handlePurchase}
disabled={isPurchasing || !selectedPackage}
className="bg-indigo-600 rounded-2xl py-4 shadow-lg"
style={{ opacity: isPurchasing ? 0.5 : 1 }}
>
{isPurchasing ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-white text-center text-lg font-semibold">
Weiter mit Premium
</Text>
)}
</TouchableOpacity>
</View>
{/* Restore & Terms */}
<View className="px-6 pb-6">
<TouchableOpacity onPress={handleRestore} disabled={isPurchasing} className="py-2">
<Text className="text-center text-indigo-600 font-medium">Käufe wiederherstellen</Text>
</TouchableOpacity>
<Text className="text-center text-xs text-gray-400 mt-4">
Mit dem Kauf stimmst du unseren AGB und Datenschutzbestimmungen zu.
{'\n'}Abos verlängern sich automatisch, können aber jederzeit gekündigt werden.
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
function FeatureRow({
icon,
text,
subtext
}: {
icon: string;
text: string;
subtext?: string;
}) {
return (
<View className="flex-row items-center py-2">
<View className="w-9 h-9 rounded-full bg-indigo-100 items-center justify-center mr-3">
<Ionicons name={icon as any} size={18} color="#6366F1" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-gray-900">{text}</Text>
{subtext && (
<Text className="text-xs text-gray-500">{subtext}</Text>
)}
</View>
<Ionicons name="checkmark-circle" size={22} color="#10B981" />
</View>
);
}
function FeatureRow({ icon, text, subtext }: { icon: string; text: string; subtext?: string }) {
return (
<View className="flex-row items-center py-2">
<View className="w-9 h-9 rounded-full bg-indigo-100 items-center justify-center mr-3">
<Ionicons name={icon as any} size={18} color="#6366F1" />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-gray-900">{text}</Text>
{subtext && <Text className="text-xs text-gray-500">{subtext}</Text>}
</View>
<Ionicons name="checkmark-circle" size={22} color="#10B981" />
</View>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,28 @@
{
"fill" : "system-dark",
"groups" : [
{
"layers" : [
{
"fill" : {
"solid" : "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"image-name" : "Zitare App Icon 02.svg",
"name" : "Zitare App Icon 02"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
"fill": "system-dark",
"groups": [
{
"layers": [
{
"fill": {
"solid": "extended-srgb:0.00000,0.53333,1.00000,1.00000"
},
"image-name": "Zitare App Icon 02.svg",
"name": "Zitare App Icon 02"
}
],
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}

View file

@ -12,440 +12,412 @@ import { useTheme } from '~/hooks/useTheme';
import { AuthorAvatar } from '~/components/authors/AuthorAvatar';
import { useQuotesStore } from '~/store/quotesStore';
import FavoriteButton from '~/components/common/FavoriteButton';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
FadeInRight,
FadeInUp,
interpolate,
Extrapolate,
SharedValue
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
FadeInRight,
FadeInUp,
interpolate,
Extrapolate,
SharedValue,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
export type AuthorCardVariant = 'simple' | 'enhanced' | 'vertical';
interface AuthorCardProps {
author: Author;
onPress?: (author: Author) => void;
index?: number;
variant?: AuthorCardVariant;
isFavorite?: boolean;
onToggleFavorite?: (authorId: string) => void;
// For vertical variant
scrollY?: SharedValue<number>;
cardHeight?: number;
author: Author;
onPress?: (author: Author) => void;
index?: number;
variant?: AuthorCardVariant;
isFavorite?: boolean;
onToggleFavorite?: (authorId: string) => void;
// For vertical variant
scrollY?: SharedValue<number>;
cardHeight?: number;
}
function AuthorCard({
author,
onPress,
index = 0,
variant = 'simple',
isFavorite,
onToggleFavorite,
scrollY,
cardHeight = 300
function AuthorCard({
author,
onPress,
index = 0,
variant = 'simple',
isFavorite,
onToggleFavorite,
scrollY,
cardHeight = 300,
}: AuthorCardProps) {
const router = useRouter();
const { t } = useTranslation();
const { enableHaptics } = useThemeStore();
const isDarkMode = useIsDarkMode();
const { getCategoryGradient } = useTheme();
const { toggleAuthorFavorite, isAuthorFavorite } = useQuotesStore();
const scale = useSharedValue(1);
// Use prop or store value for favorite status
const favoriteStatus = isFavorite !== undefined ? isFavorite : isAuthorFavorite(author.id);
const handleFavoriteToggle = onToggleFavorite || (() => toggleAuthorFavorite(author.id));
const router = useRouter();
const { t } = useTranslation();
const { enableHaptics } = useThemeStore();
const isDarkMode = useIsDarkMode();
const { getCategoryGradient } = useTheme();
const { toggleAuthorFavorite, isAuthorFavorite } = useQuotesStore();
const handlePressIn = () => {
if (variant === 'enhanced') {
scale.value = withSpring(0.98);
}
};
const scale = useSharedValue(1);
const handlePressOut = () => {
if (variant === 'enhanced') {
scale.value = withSpring(1);
}
};
// Use prop or store value for favorite status
const favoriteStatus = isFavorite !== undefined ? isFavorite : isAuthorFavorite(author.id);
const handleFavoriteToggle = onToggleFavorite || (() => toggleAuthorFavorite(author.id));
const handlePress = () => {
if (enableHaptics) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
if (onPress) {
onPress(author);
} else {
router.push(`/author/${author.id}`);
}
};
const handlePressIn = () => {
if (variant === 'enhanced') {
scale.value = withSpring(0.98);
}
};
const handleFavoritePress = () => {
handleFavoriteToggle(author.id);
};
const handlePressOut = () => {
if (variant === 'enhanced') {
scale.value = withSpring(1);
}
};
const handleShare = async () => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}\n\n${author.bio || ''}`;
const result = await Share.share({
message: authorInfo,
title: author.name,
});
const handlePress = () => {
if (enableHaptics) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
if (result.action === Share.sharedAction) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
Alert.alert(t('common.shareError'), t('common.shareErrorMessage'));
}
};
if (onPress) {
onPress(author);
} else {
router.push(`/author/${author.id}`);
}
};
const handleCopyToClipboard = async () => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}`;
await Clipboard.setStringAsync(authorInfo);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
if (Platform.OS === 'ios') {
Alert.alert(t('common.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark'
});
}
} catch (error) {
Alert.alert(t('common.copyError'), t('common.copyErrorMessage'));
}
};
const handleFavoritePress = () => {
handleFavoriteToggle(author.id);
};
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }]
}));
const handleShare = async () => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}\n\n${author.bio || ''}`;
const verticalAnimatedStyle = useAnimatedStyle(() => {
if (variant !== 'vertical' || !scrollY) {
return {};
}
const cardPosition = index * cardHeight;
const inputRange = [
cardPosition - cardHeight,
cardPosition,
cardPosition + cardHeight,
];
const opacity = interpolate(
scrollY.value,
inputRange,
[0.3, 1, 0.3],
Extrapolate.CLAMP
);
const cardScale = interpolate(
scrollY.value,
inputRange,
[0.85, 1, 0.85],
Extrapolate.CLAMP
);
return {
opacity,
transform: [{ scale: cardScale }],
};
});
const getLifeYears = () => {
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 variant === 'enhanced' ? `${t('authors.born')} ${birth}` : birth;
}
return null;
};
const result = await Share.share({
message: authorInfo,
title: author.name,
});
// Gradient basierend auf Featured-Status oder Profession
const getGradientColors = () => {
if (author.featured) {
return ['#f59e0b', '#ef4444']; // Amber to Red for featured
}
// Use profession to determine gradient
const profession = author.profession?.[0]?.toLowerCase() || '';
if (profession.includes('philosoph')) {
return ['#9333ea', '#6366f1']; // Purple to Indigo
} else if (profession.includes('dichter') || profession.includes('poet')) {
return ['#ec4899', '#f43f5e']; // Pink to Rose
} else if (profession.includes('wissenschaft')) {
return ['#3b82f6', '#06b6d4']; // Blue to Cyan
} else if (profession.includes('schrift')) {
return ['#10b981', '#14b8a6']; // Emerald to Teal
}
return ['#6366f1', '#8b5cf6']; // Default: Indigo to Violet
};
if (result.action === Share.sharedAction) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
Alert.alert(t('common.shareError'), t('common.shareErrorMessage'));
}
};
const renderSimpleCard = () => (
<Animated.View
entering={FadeInRight.delay(index * 50).duration(600).springify()}
className="px-6 mb-4"
>
<Pressable
onPress={handlePress}
>
<LinearGradient
colors={isDarkMode ? ['#1e293b', '#334155'] : ['#334155', '#1e293b']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
borderRadius: 16,
padding: 1
}}
>
<View className="bg-black/40 rounded-2xl backdrop-blur-xl">
<View className="p-4">
<View className="flex-row items-center">
<AuthorAvatar
name={author.name}
imageUrl={author.image?.thumbnail || author.image?.full}
size="medium"
/>
const handleCopyToClipboard = async () => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
<View className="flex-1 ml-4">
<View className="flex-row items-center justify-between">
<View className="flex-1 mr-2">
<Text variant="bodyLarge" weight="semibold" style={{ color: 'white' }}>
{author.name}
</Text>
{author.lifespan && (
<Text variant="caption" className="mt-0.5" style={{ color: 'rgba(255,255,255,0.6)' }}>
{getLifeYears()}
</Text>
)}
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}`;
await Clipboard.setStringAsync(authorInfo);
{author.profession && author.profession.length > 0 && (
<Text variant="caption" className="mt-1" style={{ color: 'rgba(255,255,255,0.6)' }}>
{author.profession[0]}
</Text>
)}
</View>
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
{/* Action Buttons */}
<View className="flex-row items-center gap-3">
{/* Copy Button */}
<Pressable
onPress={handleCopyToClipboard}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="copy-outline"
size={22}
color="rgba(255,255,255,0.8)"
/>
</Pressable>
if (Platform.OS === 'ios') {
Alert.alert(t('common.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark',
});
}
} catch (error) {
Alert.alert(t('common.copyError'), t('common.copyErrorMessage'));
}
};
{/* Share Button */}
<Pressable
onPress={handleShare}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="share-outline"
size={22}
color="rgba(255,255,255,0.8)"
/>
</Pressable>
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
{/* Favorite Button */}
<FavoriteButton
isFavorite={favoriteStatus}
onToggle={handleFavoritePress}
size={24}
/>
</View>
</View>
</View>
</View>
</View>
</View>
</LinearGradient>
</Pressable>
</Animated.View>
);
const verticalAnimatedStyle = useAnimatedStyle(() => {
if (variant !== 'vertical' || !scrollY) {
return {};
}
const renderEnhancedCard = () => (
<Animated.View style={animatedStyle}>
<Pressable
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
>
<LinearGradient
colors={getGradientColors()}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
borderRadius: 24,
padding: 1.5
}}
>
<View className="bg-black/40 rounded-3xl backdrop-blur-xl">
<View className="p-5">
{/* Main Content */}
<View className="flex-row items-center">
{/* Avatar */}
<View className="mr-4">
<AuthorAvatar
name={author.name}
imageUrl={author.image?.thumbnail}
size="medium"
/>
</View>
const cardPosition = index * cardHeight;
const inputRange = [cardPosition - cardHeight, cardPosition, cardPosition + cardHeight];
{/* Author Info */}
<View className="flex-1">
<Text variant="h4" weight="medium" className="mb-1" style={{ color: 'white' }}>
{author.name}
</Text>
{/* Lebensjahre */}
{getLifeYears() && (
<Text variant="bodySmall" style={{ color: 'rgba(255,255,255,0.7)' }}>
{getLifeYears()}
</Text>
)}
</View>
const opacity = interpolate(scrollY.value, inputRange, [0.3, 1, 0.3], Extrapolate.CLAMP);
{/* Arrow */}
<View className="ml-2">
<Icon
name="chevron-forward"
size={25}
color="rgba(255,255,255,0.5)"
/>
</View>
</View>
const cardScale = interpolate(scrollY.value, inputRange, [0.85, 1, 0.85], Extrapolate.CLAMP);
{/* Bio wenn vorhanden */}
{author.biography?.short && (
<View className="border-t border-white/10 mt-4 pt-3">
<Text
variant="bodySmall"
className="leading-relaxed"
numberOfLines={2}
style={{ color: 'rgba(255,255,255,0.7)' }}
>
{author.biography.short}
</Text>
</View>
)}
return {
opacity,
transform: [{ scale: cardScale }],
};
});
{/* Professions und Action Buttons */}
<View className="flex-row items-end justify-between mt-3">
{/* Professions links */}
<View className="flex-1 flex-row flex-wrap">
{author.profession && author.profession.length > 0 && (
<>
{author.profession.slice(0, 2).map((prof, idx) => (
<View
key={idx}
className="bg-white/10 px-2.5 py-1 rounded-full mr-2 mb-2"
>
<Text variant="caption" className="opacity-70" style={{ color: 'white' }}>
{prof}
</Text>
</View>
))}
{author.profession.length > 2 && (
<Text variant="caption" className="self-center" style={{ color: 'rgba(255,255,255,0.5)' }}>
+{author.profession.length - 2}
</Text>
)}
</>
)}
</View>
{/* Action Buttons rechts */}
<View className="flex-row items-center gap-3">
{/* Copy Button */}
<Pressable
onPress={handleCopyToClipboard}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="copy-outline"
size={22}
color="rgba(255,255,255,0.7)"
/>
</Pressable>
const getLifeYears = () => {
if (!author.lifespan) return null;
const birth = author.lifespan.birth?.substring(0, 4);
const death = author.lifespan.death?.substring(0, 4);
{/* Share Button */}
<Pressable
onPress={handleShare}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="share-outline"
size={22}
color="rgba(255,255,255,0.7)"
/>
</Pressable>
{/* Favorite Button */}
<FavoriteButton
isFavorite={favoriteStatus}
onToggle={handleFavoritePress}
size={24}
/>
</View>
</View>
</View>
</View>
</LinearGradient>
</Pressable>
</Animated.View>
);
if (birth && death) {
return `${birth} ${death}`;
}
if (birth) {
return variant === 'enhanced' ? `${t('authors.born')} ${birth}` : birth;
}
return null;
};
if (variant === 'vertical') {
return (
<View style={{
height: cardHeight,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16
}}>
<Animated.View style={[verticalAnimatedStyle, { width: '100%', maxWidth: 400 }]}>
{renderEnhancedCard()}
</Animated.View>
</View>
);
}
// Gradient basierend auf Featured-Status oder Profession
const getGradientColors = () => {
if (author.featured) {
return ['#f59e0b', '#ef4444']; // Amber to Red for featured
}
// Use profession to determine gradient
const profession = author.profession?.[0]?.toLowerCase() || '';
if (profession.includes('philosoph')) {
return ['#9333ea', '#6366f1']; // Purple to Indigo
} else if (profession.includes('dichter') || profession.includes('poet')) {
return ['#ec4899', '#f43f5e']; // Pink to Rose
} else if (profession.includes('wissenschaft')) {
return ['#3b82f6', '#06b6d4']; // Blue to Cyan
} else if (profession.includes('schrift')) {
return ['#10b981', '#14b8a6']; // Emerald to Teal
}
return ['#6366f1', '#8b5cf6']; // Default: Indigo to Violet
};
if (variant === 'enhanced') {
return renderEnhancedCard();
}
const renderSimpleCard = () => (
<Animated.View
entering={FadeInRight.delay(index * 50)
.duration(600)
.springify()}
className="px-6 mb-4"
>
<Pressable onPress={handlePress}>
<LinearGradient
colors={isDarkMode ? ['#1e293b', '#334155'] : ['#334155', '#1e293b']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
borderRadius: 16,
padding: 1,
}}
>
<View className="bg-black/40 rounded-2xl backdrop-blur-xl">
<View className="p-4">
<View className="flex-row items-center">
<AuthorAvatar
name={author.name}
imageUrl={author.image?.thumbnail || author.image?.full}
size="medium"
/>
return renderSimpleCard();
<View className="flex-1 ml-4">
<View className="flex-row items-center justify-between">
<View className="flex-1 mr-2">
<Text variant="bodyLarge" weight="semibold" style={{ color: 'white' }}>
{author.name}
</Text>
{author.lifespan && (
<Text
variant="caption"
className="mt-0.5"
style={{ color: 'rgba(255,255,255,0.6)' }}
>
{getLifeYears()}
</Text>
)}
{author.profession && author.profession.length > 0 && (
<Text
variant="caption"
className="mt-1"
style={{ color: 'rgba(255,255,255,0.6)' }}
>
{author.profession[0]}
</Text>
)}
</View>
{/* Action Buttons */}
<View className="flex-row items-center gap-3">
{/* Copy Button */}
<Pressable
onPress={handleCopyToClipboard}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="copy-outline" size={22} color="rgba(255,255,255,0.8)" />
</Pressable>
{/* Share Button */}
<Pressable
onPress={handleShare}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="share-outline" size={22} color="rgba(255,255,255,0.8)" />
</Pressable>
{/* Favorite Button */}
<FavoriteButton
isFavorite={favoriteStatus}
onToggle={handleFavoritePress}
size={24}
/>
</View>
</View>
</View>
</View>
</View>
</View>
</LinearGradient>
</Pressable>
</Animated.View>
);
const renderEnhancedCard = () => (
<Animated.View style={animatedStyle}>
<Pressable onPress={handlePress} onPressIn={handlePressIn} onPressOut={handlePressOut}>
<LinearGradient
colors={getGradientColors()}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
borderRadius: 24,
padding: 1.5,
}}
>
<View className="bg-black/40 rounded-3xl backdrop-blur-xl">
<View className="p-5">
{/* Main Content */}
<View className="flex-row items-center">
{/* Avatar */}
<View className="mr-4">
<AuthorAvatar
name={author.name}
imageUrl={author.image?.thumbnail}
size="medium"
/>
</View>
{/* Author Info */}
<View className="flex-1">
<Text variant="h4" weight="medium" className="mb-1" style={{ color: 'white' }}>
{author.name}
</Text>
{/* Lebensjahre */}
{getLifeYears() && (
<Text variant="bodySmall" style={{ color: 'rgba(255,255,255,0.7)' }}>
{getLifeYears()}
</Text>
)}
</View>
{/* Arrow */}
<View className="ml-2">
<Icon name="chevron-forward" size={25} color="rgba(255,255,255,0.5)" />
</View>
</View>
{/* Bio wenn vorhanden */}
{author.biography?.short && (
<View className="border-t border-white/10 mt-4 pt-3">
<Text
variant="bodySmall"
className="leading-relaxed"
numberOfLines={2}
style={{ color: 'rgba(255,255,255,0.7)' }}
>
{author.biography.short}
</Text>
</View>
)}
{/* Professions und Action Buttons */}
<View className="flex-row items-end justify-between mt-3">
{/* Professions links */}
<View className="flex-1 flex-row flex-wrap">
{author.profession && author.profession.length > 0 && (
<>
{author.profession.slice(0, 2).map((prof, idx) => (
<View key={idx} className="bg-white/10 px-2.5 py-1 rounded-full mr-2 mb-2">
<Text variant="caption" className="opacity-70" style={{ color: 'white' }}>
{prof}
</Text>
</View>
))}
{author.profession.length > 2 && (
<Text
variant="caption"
className="self-center"
style={{ color: 'rgba(255,255,255,0.5)' }}
>
+{author.profession.length - 2}
</Text>
)}
</>
)}
</View>
{/* Action Buttons rechts */}
<View className="flex-row items-center gap-3">
{/* Copy Button */}
<Pressable
onPress={handleCopyToClipboard}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="copy-outline" size={22} color="rgba(255,255,255,0.7)" />
</Pressable>
{/* Share Button */}
<Pressable
onPress={handleShare}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="share-outline" size={22} color="rgba(255,255,255,0.7)" />
</Pressable>
{/* Favorite Button */}
<FavoriteButton
isFavorite={favoriteStatus}
onToggle={handleFavoritePress}
size={24}
/>
</View>
</View>
</View>
</View>
</LinearGradient>
</Pressable>
</Animated.View>
);
if (variant === 'vertical') {
return (
<View
style={{
height: cardHeight,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
}}
>
<Animated.View style={[verticalAnimatedStyle, { width: '100%', maxWidth: 400 }]}>
{renderEnhancedCard()}
</Animated.View>
</View>
);
}
if (variant === 'enhanced') {
return renderEnhancedCard();
}
return renderSimpleCard();
}
// Optimierung mit React.memo
export default React.memo(AuthorCard, (prevProps, nextProps) => {
return (
prevProps.author.id === nextProps.author.id &&
prevProps.variant === nextProps.variant &&
prevProps.isFavorite === nextProps.isFavorite
);
});
return (
prevProps.author.id === nextProps.author.id &&
prevProps.variant === nextProps.variant &&
prevProps.isFavorite === nextProps.isFavorite
);
});

View file

@ -1,5 +1,13 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Alert, ActivityIndicator, Platform, Pressable } from 'react-native';
import {
View,
Text,
TouchableOpacity,
Alert,
ActivityIndicator,
Platform,
Pressable,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQuotesStore } from '../store/quotesStore';
import { CloudSyncService } from '../services/cloudSync/cloudSyncService';
@ -7,185 +15,190 @@ import { useIsDarkMode } from '~/store/settingsStore';
import * as Haptics from 'expo-haptics';
interface CloudSyncButtonProps {
className?: string;
className?: string;
}
export const CloudSyncButton: React.FC<CloudSyncButtonProps> = ({ className = '' }) => {
const [isLoading, setIsLoading] = useState(false);
const { exportToCloud, importFromCloud, lastSyncDate, isSyncing } = useQuotesStore();
const isDarkMode = useIsDarkMode();
const [isLoading, setIsLoading] = useState(false);
const { exportToCloud, importFromCloud, lastSyncDate, isSyncing } = useQuotesStore();
const isDarkMode = useIsDarkMode();
const formatLastSyncDate = () => {
if (!lastSyncDate) return 'Noch nie synchronisiert';
const date = new Date(lastSyncDate);
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000);
if (diffInMinutes < 1) return 'Gerade eben';
if (diffInMinutes < 60) return `Vor ${diffInMinutes} Minuten`;
if (diffInMinutes < 1440) return `Vor ${Math.floor(diffInMinutes / 60)} Stunden`;
return date.toLocaleDateString('de-DE');
};
const formatLastSyncDate = () => {
if (!lastSyncDate) return 'Noch nie synchronisiert';
const handleExport = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setIsLoading(true);
try {
const authenticated = await CloudSyncService.authenticate();
if (!authenticated && Platform.OS === 'android') {
Alert.alert(
'Authentifizierung fehlgeschlagen',
'Bitte überprüfe deine Kontoeinstellungen'
);
return;
}
const date = new Date(lastSyncDate);
const now = new Date();
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000);
const success = await exportToCloud();
if (Platform.OS === 'ios') {
// On iOS, the share sheet handles the feedback
return;
}
Alert.alert(
success ? 'Export erfolgreich' : 'Export fehlgeschlagen',
success
? 'Deine Daten wurden erfolgreich gesichert'
: 'Backup konnte nicht erstellt werden. Bitte versuche es erneut.'
);
} catch (error) {
Alert.alert('Fehler', 'Ein Fehler ist beim Backup aufgetreten');
} finally {
setIsLoading(false);
}
};
if (diffInMinutes < 1) return 'Gerade eben';
if (diffInMinutes < 60) return `Vor ${diffInMinutes} Minuten`;
if (diffInMinutes < 1440) return `Vor ${Math.floor(diffInMinutes / 60)} Stunden`;
return date.toLocaleDateString('de-DE');
};
const handleImport = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Alert.alert(
'Daten wiederherstellen',
'Möchtest du deine gespeicherten Daten wiederherstellen? Neue Favoriten werden hinzugefügt, bestehende bleiben erhalten.',
[
{
text: 'Abbrechen',
style: 'cancel'
},
{
text: 'Wiederherstellen',
onPress: async () => {
setIsLoading(true);
try {
const success = await importFromCloud();
Alert.alert(
success ? 'Import erfolgreich' : 'Import fehlgeschlagen',
success
? 'Deine Daten wurden erfolgreich wiederhergestellt'
: 'Daten konnten nicht wiederhergestellt werden. Bitte wähle eine gültige Backup-Datei.'
);
} catch (error) {
Alert.alert('Fehler', 'Ein Fehler ist bei der Wiederherstellung aufgetreten');
} finally {
setIsLoading(false);
}
}
}
]
);
};
const handleExport = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setIsLoading(true);
try {
const authenticated = await CloudSyncService.authenticate();
if (!authenticated && Platform.OS === 'android') {
Alert.alert('Authentifizierung fehlgeschlagen', 'Bitte überprüfe deine Kontoeinstellungen');
return;
}
return (
<View className={className}>
{/* Main Backup Actions */}
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl`}>
{/* Export/Backup Button */}
<Pressable
onPress={handleExport}
disabled={isLoading || isSyncing}
className={`flex-row justify-between items-center p-4 ${
(isLoading || isSyncing) ? 'opacity-50' : ''
} border-b ${isDarkMode ? 'border-white/5' : 'border-black/5'}`}
>
<View className="flex-1">
<View className="flex-row items-center">
<Ionicons
name={Platform.OS === 'ios' ? 'cloud-upload' : 'cloud-upload-outline'}
size={20}
color={isDarkMode ? '#60A5FA' : '#3B82F6'}
style={{ marginRight: 12 }}
/>
<View className="flex-1">
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-base font-medium`}>
Backup erstellen
</Text>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-xs mt-0.5`}>
Sichere deine Daten in {Platform.OS === 'ios' ? 'iCloud' : 'Google Drive'}
</Text>
</View>
</View>
</View>
{(isLoading || isSyncing) ? (
<ActivityIndicator size="small" color={isDarkMode ? '#60A5FA' : '#3B82F6'} />
) : (
<Ionicons
name="chevron-forward"
size={20}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
)}
</Pressable>
const success = await exportToCloud();
{/* Import/Restore Button */}
<Pressable
onPress={handleImport}
disabled={isLoading || isSyncing}
className={`flex-row justify-between items-center p-4 ${
(isLoading || isSyncing) ? 'opacity-50' : ''
}`}
>
<View className="flex-1">
<View className="flex-row items-center">
<Ionicons
name={Platform.OS === 'ios' ? 'cloud-download' : 'cloud-download-outline'}
size={20}
color={isDarkMode ? '#10B981' : '#059669'}
style={{ marginRight: 12 }}
/>
<View className="flex-1">
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-base font-medium`}>
Wiederherstellen
</Text>
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-xs mt-0.5`}>
Lade dein letztes Backup
</Text>
</View>
</View>
</View>
{(isLoading || isSyncing) ? (
<ActivityIndicator size="small" color={isDarkMode ? '#10B981' : '#059669'} />
) : (
<Ionicons
name="chevron-forward"
size={20}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
)}
</Pressable>
</View>
if (Platform.OS === 'ios') {
// On iOS, the share sheet handles the feedback
return;
}
{/* Last Sync Info */}
{lastSyncDate && (
<View className="mt-3 flex-row items-center justify-center">
<Ionicons
name="checkmark-circle"
size={14}
color={isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
/>
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-xs ml-1.5`}>
Letzte Synchronisation: {formatLastSyncDate()}
</Text>
</View>
)}
</View>
);
};
Alert.alert(
success ? 'Export erfolgreich' : 'Export fehlgeschlagen',
success
? 'Deine Daten wurden erfolgreich gesichert'
: 'Backup konnte nicht erstellt werden. Bitte versuche es erneut.'
);
} catch (error) {
Alert.alert('Fehler', 'Ein Fehler ist beim Backup aufgetreten');
} finally {
setIsLoading(false);
}
};
const handleImport = async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
Alert.alert(
'Daten wiederherstellen',
'Möchtest du deine gespeicherten Daten wiederherstellen? Neue Favoriten werden hinzugefügt, bestehende bleiben erhalten.',
[
{
text: 'Abbrechen',
style: 'cancel',
},
{
text: 'Wiederherstellen',
onPress: async () => {
setIsLoading(true);
try {
const success = await importFromCloud();
Alert.alert(
success ? 'Import erfolgreich' : 'Import fehlgeschlagen',
success
? 'Deine Daten wurden erfolgreich wiederhergestellt'
: 'Daten konnten nicht wiederhergestellt werden. Bitte wähle eine gültige Backup-Datei.'
);
} catch (error) {
Alert.alert('Fehler', 'Ein Fehler ist bei der Wiederherstellung aufgetreten');
} finally {
setIsLoading(false);
}
},
},
]
);
};
return (
<View className={className}>
{/* Main Backup Actions */}
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl`}>
{/* Export/Backup Button */}
<Pressable
onPress={handleExport}
disabled={isLoading || isSyncing}
className={`flex-row justify-between items-center p-4 ${
isLoading || isSyncing ? 'opacity-50' : ''
} border-b ${isDarkMode ? 'border-white/5' : 'border-black/5'}`}
>
<View className="flex-1">
<View className="flex-row items-center">
<Ionicons
name={Platform.OS === 'ios' ? 'cloud-upload' : 'cloud-upload-outline'}
size={20}
color={isDarkMode ? '#60A5FA' : '#3B82F6'}
style={{ marginRight: 12 }}
/>
<View className="flex-1">
<Text
className={`${isDarkMode ? 'text-white' : 'text-black'} text-base font-medium`}
>
Backup erstellen
</Text>
<Text
className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-xs mt-0.5`}
>
Sichere deine Daten in {Platform.OS === 'ios' ? 'iCloud' : 'Google Drive'}
</Text>
</View>
</View>
</View>
{isLoading || isSyncing ? (
<ActivityIndicator size="small" color={isDarkMode ? '#60A5FA' : '#3B82F6'} />
) : (
<Ionicons
name="chevron-forward"
size={20}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
)}
</Pressable>
{/* Import/Restore Button */}
<Pressable
onPress={handleImport}
disabled={isLoading || isSyncing}
className={`flex-row justify-between items-center p-4 ${
isLoading || isSyncing ? 'opacity-50' : ''
}`}
>
<View className="flex-1">
<View className="flex-row items-center">
<Ionicons
name={Platform.OS === 'ios' ? 'cloud-download' : 'cloud-download-outline'}
size={20}
color={isDarkMode ? '#10B981' : '#059669'}
style={{ marginRight: 12 }}
/>
<View className="flex-1">
<Text
className={`${isDarkMode ? 'text-white' : 'text-black'} text-base font-medium`}
>
Wiederherstellen
</Text>
<Text
className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} text-xs mt-0.5`}
>
Lade dein letztes Backup
</Text>
</View>
</View>
</View>
{isLoading || isSyncing ? (
<ActivityIndicator size="small" color={isDarkMode ? '#10B981' : '#059669'} />
) : (
<Ionicons
name="chevron-forward"
size={20}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
)}
</Pressable>
</View>
{/* Last Sync Info */}
{lastSyncDate && (
<View className="mt-3 flex-row items-center justify-center">
<Ionicons
name="checkmark-circle"
size={14}
color={isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
/>
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-xs ml-1.5`}>
Letzte Synchronisation: {formatLastSyncDate()}
</Text>
</View>
)}
</View>
);
};

View file

@ -11,154 +11,155 @@ import * as Haptics from 'expo-haptics';
import { withTranslation, WithTranslation } from 'react-i18next';
interface Props extends WithTranslation {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundaryClass extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
errorInfo: null,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('=================================');
console.error('ErrorBoundary caught an error:');
console.error('Error:', error);
console.error('Error Message:', error.message);
console.error('Error Stack:', error.stack);
console.error('Component Stack:', errorInfo.componentStack);
console.error('=================================');
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('=================================');
console.error('ErrorBoundary caught an error:');
console.error('Error:', error);
console.error('Error Message:', error.message);
console.error('Error Stack:', error.stack);
console.error('Component Stack:', errorInfo.componentStack);
console.error('=================================');
this.setState({
error,
errorInfo
});
this.setState({
error,
errorInfo,
});
// Callback für externes Error-Logging
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
// Callback für externes Error-Logging
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
handleReset = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
this.setState({
hasError: false,
error: null,
errorInfo: null
});
};
handleReset = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
// Custom fallback UI wenn vorhanden
if (this.props.fallback) {
return this.props.fallback;
}
render() {
if (this.state.hasError) {
// Custom fallback UI wenn vorhanden
if (this.props.fallback) {
return this.props.fallback;
}
// Standard Error UI
return (
<SafeAreaView className="flex-1 bg-white dark:bg-black">
<ScrollView
className="flex-1"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20
}}
>
<View className="items-center max-w-sm">
<View className="bg-red-100 dark:bg-red-900/20 p-4 rounded-full mb-6">
<Icon name="warning-outline" size={48} color="#ef4444" />
</View>
<Text className="text-2xl font-bold text-black dark:text-white mb-2 text-center">
{this.props.t('errors.somethingWrong') || 'Ups, etwas ist schiefgelaufen!'}
</Text>
// Standard Error UI
return (
<SafeAreaView className="flex-1 bg-white dark:bg-black">
<ScrollView
className="flex-1"
contentContainerStyle={{
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
}}
>
<View className="items-center max-w-sm">
<View className="bg-red-100 dark:bg-red-900/20 p-4 rounded-full mb-6">
<Icon name="warning-outline" size={48} color="#ef4444" />
</View>
<Text className="text-gray-600 dark:text-gray-400 text-center mb-6">
{this.props.t('errors.unexpectedError') || 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut oder starte die App neu.'}
</Text>
<Text className="text-2xl font-bold text-black dark:text-white mb-2 text-center">
{this.props.t('errors.somethingWrong') || 'Ups, etwas ist schiefgelaufen!'}
</Text>
{/* Error Details (nur in Development) */}
{__DEV__ && this.state.error && (
<View className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg mb-6 w-full">
<Text className="text-red-600 dark:text-red-400 font-mono text-xs mb-2">
{this.state.error.toString()}
</Text>
{this.state.errorInfo && (
<Text className="text-gray-600 dark:text-gray-400 font-mono text-xs">
{this.state.errorInfo.componentStack}
</Text>
)}
</View>
)}
<Text className="text-gray-600 dark:text-gray-400 text-center mb-6">
{this.props.t('errors.unexpectedError') ||
'Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut oder starte die App neu.'}
</Text>
<Pressable
onPress={this.handleReset}
className="bg-black dark:bg-white px-6 py-3 rounded-full"
>
<Text className="text-white dark:text-black font-semibold">
{this.props.t('common.retry') || 'Erneut versuchen'}
</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
{/* Error Details (nur in Development) */}
{__DEV__ && this.state.error && (
<View className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg mb-6 w-full">
<Text className="text-red-600 dark:text-red-400 font-mono text-xs mb-2">
{this.state.error.toString()}
</Text>
{this.state.errorInfo && (
<Text className="text-gray-600 dark:text-gray-400 font-mono text-xs">
{this.state.errorInfo.componentStack}
</Text>
)}
</View>
)}
return this.props.children;
}
<Pressable
onPress={this.handleReset}
className="bg-black dark:bg-white px-6 py-3 rounded-full"
>
<Text className="text-white dark:text-black font-semibold">
{this.props.t('common.retry') || 'Erneut versuchen'}
</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
return this.props.children;
}
}
// Hook für funktionale Komponenten
export const useErrorHandler = () => {
const [error, setError] = React.useState<Error | null>(null);
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
if (error) {
throw error;
}
}, [error]);
React.useEffect(() => {
if (error) {
throw error;
}
}, [error]);
const resetError = () => setError(null);
const throwError = (error: Error) => setError(error);
const resetError = () => setError(null);
const throwError = (error: Error) => setError(error);
return { throwError, resetError };
return { throwError, resetError };
};
export const ErrorBoundary = withTranslation()(ErrorBoundaryClass);
// HOC für Error Boundary
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode
Component: React.ComponentType<P>,
fallback?: ReactNode
) {
return (props: P) => (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
}
return (props: P) => (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
}

View file

@ -3,158 +3,162 @@ import { Ionicons } from '@expo/vector-icons';
import { StyleProp, ViewStyle } from 'react-native';
// Definiere alle verwendeten Icons zentral
export type IconName =
// Navigation & UI
| 'chevron-back'
| 'chevron-forward'
| 'close'
| 'search'
| 'filter'
| 'settings'
| 'settings-outline'
| 'menu'
| 'apps'
| 'swap-vertical-outline'
| 'create'
| 'create-outline'
| 'checkmark'
| 'checkmark-outline'
| 'list-outline'
| 'grid-outline'
// Actions
| 'heart'
| 'heart-outline'
| 'star'
| 'star-outline'
| 'share'
| 'share-outline'
| 'copy'
| 'copy-outline'
| 'trash'
| 'trash-outline'
| 'refresh'
| 'shuffle'
| 'play-circle'
| 'notifications'
| 'add'
| 'add-outline'
| 'add-circle'
| 'add-circle-outline'
// Content
| 'book'
| 'book-outline'
| 'person'
| 'person-outline'
| 'people'
| 'people-outline'
| 'text'
| 'text-outline'
| 'stats-chart-outline'
// Weather & Time
| 'sunny'
| 'moon'
| 'time'
| 'calendar'
// Status & Info
| 'checkmark-circle'
| 'information-circle'
| 'warning'
| 'alert-circle'
// Media & Communication
| 'phone-portrait'
| 'globe'
| 'globe-outline'
| 'link'
| 'link-outline'
// Categories (custom mapping)
| 'bulb'
| 'bulb-outline'
| 'rocket'
| 'trophy'
| 'leaf'
| 'happy'
| 'happy-outline'
| 'flask'
| 'color-palette'
| 'flash'
| 'flower'
| 'shield'
| 'pricetag';
export type IconName =
// Navigation & UI
| 'chevron-back'
| 'chevron-forward'
| 'close'
| 'search'
| 'filter'
| 'settings'
| 'settings-outline'
| 'menu'
| 'apps'
| 'swap-vertical-outline'
| 'create'
| 'create-outline'
| 'checkmark'
| 'checkmark-outline'
| 'list-outline'
| 'grid-outline'
// Actions
| 'heart'
| 'heart-outline'
| 'star'
| 'star-outline'
| 'share'
| 'share-outline'
| 'copy'
| 'copy-outline'
| 'trash'
| 'trash-outline'
| 'refresh'
| 'shuffle'
| 'play-circle'
| 'notifications'
| 'add'
| 'add-outline'
| 'add-circle'
| 'add-circle-outline'
// Content
| 'book'
| 'book-outline'
| 'person'
| 'person-outline'
| 'people'
| 'people-outline'
| 'text'
| 'text-outline'
| 'stats-chart-outline'
// Weather & Time
| 'sunny'
| 'moon'
| 'time'
| 'calendar'
// Status & Info
| 'checkmark-circle'
| 'information-circle'
| 'warning'
| 'alert-circle'
// Media & Communication
| 'phone-portrait'
| 'globe'
| 'globe-outline'
| 'link'
| 'link-outline'
// Categories (custom mapping)
| 'bulb'
| 'bulb-outline'
| 'rocket'
| 'trophy'
| 'leaf'
| 'happy'
| 'happy-outline'
| 'flask'
| 'color-palette'
| 'flash'
| 'flower'
| 'shield'
| 'pricetag';
interface IconProps {
name: IconName;
size?: number;
color?: string;
style?: StyleProp<ViewStyle>;
focused?: boolean;
name: IconName;
size?: number;
color?: string;
style?: StyleProp<ViewStyle>;
focused?: boolean;
}
// Standard-Farben
const colors = {
white: '#ffffff',
black: '#000000',
gray: {
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
},
red: '#ef4444',
yellow: '#fbbf24',
pink: '#ec4899',
transparent: {
white: {
60: 'rgba(255,255,255,0.6)',
70: 'rgba(255,255,255,0.7)',
80: 'rgba(255,255,255,0.8)',
},
black: {
60: 'rgba(0,0,0,0.6)',
}
}
white: '#ffffff',
black: '#000000',
gray: {
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
},
red: '#ef4444',
yellow: '#fbbf24',
pink: '#ec4899',
transparent: {
white: {
60: 'rgba(255,255,255,0.6)',
70: 'rgba(255,255,255,0.7)',
80: 'rgba(255,255,255,0.8)',
},
black: {
60: 'rgba(0,0,0,0.6)',
},
},
};
export const Icon: React.FC<IconProps> = ({
name,
size = 24,
color = colors.white,
style,
focused = false
name,
size = 24,
color = colors.white,
style,
focused = false,
}) => {
// Only use focused logic when explicitly provided
// Otherwise, use the icon name exactly as provided
let iconName: any = name;
// Only use focused logic when explicitly provided
// Otherwise, use the icon name exactly as provided
let iconName: any = name;
if (focused !== undefined && focused !== false) {
// If focused and icon has -outline, remove it
if (focused && name.includes('-outline')) {
iconName = name.replace('-outline', '') as any;
}
}
if (focused !== undefined && focused !== false) {
// If focused and icon has -outline, remove it
if (focused && name.includes('-outline')) {
iconName = name.replace('-outline', '') as any;
}
}
return (
<Ionicons
name={iconName}
size={size}
color={color}
style={style}
/>
);
return <Ionicons name={iconName} size={size} color={color} style={style} />;
};
// Helper-Funktion um zu prüfen ob eine Outline-Variante existiert
function hasOutlineVariant(name: string): boolean {
const outlineVariants = [
'heart', 'star', 'share', 'copy', 'trash',
'book', 'person', 'people', 'settings',
'text', 'bulb', 'happy', 'globe', 'link'
];
return outlineVariants.includes(name);
const outlineVariants = [
'heart',
'star',
'share',
'copy',
'trash',
'book',
'person',
'people',
'settings',
'text',
'bulb',
'happy',
'globe',
'link',
];
return outlineVariants.includes(name);
}
// Export der Farben für konsistente Verwendung
@ -162,37 +166,37 @@ export { colors as IconColors };
// Spezielle Icon-Sets für Kategorien
export const getCategoryIcon = (category: string): IconName => {
const categoryIcons: Record<string, IconName> = {
'wisdom': 'bulb',
'love': 'heart',
'motivation': 'rocket',
'success': 'trophy',
'life': 'leaf',
'happiness': 'happy',
'philosophy': 'book',
'science': 'flask',
'creativity': 'color-palette',
'humor': 'happy-outline',
'inspiration': 'star',
'leadership': 'people',
'innovation': 'flash',
'dreams': 'moon',
'courage': 'shield',
'mindfulness': 'flower'
};
return categoryIcons[category] || 'pricetag';
const categoryIcons: Record<string, IconName> = {
wisdom: 'bulb',
love: 'heart',
motivation: 'rocket',
success: 'trophy',
life: 'leaf',
happiness: 'happy',
philosophy: 'book',
science: 'flask',
creativity: 'color-palette',
humor: 'happy-outline',
inspiration: 'star',
leadership: 'people',
innovation: 'flash',
dreams: 'moon',
courage: 'shield',
mindfulness: 'flower',
};
return categoryIcons[category] || 'pricetag';
};
// Tab-Icon-Helper
export const getTabIcon = (routeName: string, focused: boolean): IconName => {
const tabIcons: Record<string, IconName> = {
'index': focused ? 'book' : 'book-outline',
'search': focused ? 'search' : 'search',
'authors': focused ? 'people' : 'people-outline',
'favorites': focused ? 'heart' : 'heart-outline',
'settings': focused ? 'settings' : 'settings-outline',
};
return tabIcons[routeName] || 'apps';
const tabIcons: Record<string, IconName> = {
index: focused ? 'book' : 'book-outline',
search: focused ? 'search' : 'search',
authors: focused ? 'people' : 'people-outline',
favorites: focused ? 'heart' : 'heart-outline',
settings: focused ? 'settings' : 'settings-outline',
};
return tabIcons[routeName] || 'apps';
};
export default Icon;
export default Icon;

View file

@ -1,212 +1,198 @@
import React from 'react';
import {
Modal,
View,
Text,
TouchableOpacity,
TouchableWithoutFeedback,
} from 'react-native';
import { Modal, View, Text, TouchableOpacity, TouchableWithoutFeedback } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { useIsDarkMode } from '~/store/settingsStore';
interface PremiumLimitDialogProps {
visible: boolean;
onClose: () => void;
limitType: 'favorites' | 'search' | 'collections';
remaining?: number;
max?: number;
visible: boolean;
onClose: () => void;
limitType: 'favorites' | 'search' | 'collections';
remaining?: number;
max?: number;
}
export function PremiumLimitDialog({
visible,
onClose,
limitType,
remaining = 0,
max = 5,
visible,
onClose,
limitType,
remaining = 0,
max = 5,
}: PremiumLimitDialogProps) {
const isDarkMode = useIsDarkMode();
const isDarkMode = useIsDarkMode();
const getLimitInfo = () => {
switch (limitType) {
case 'favorites':
return {
icon: 'heart',
title: 'Favoriten-Limit erreicht',
description: `Du hast deine ${max} täglichen Favoriten aufgebraucht.`,
benefit: 'Unbegrenzte Favoriten mit Premium',
};
case 'search':
return {
icon: 'search',
title: 'Such-Limit erreicht',
description: `Du hast deine ${max} täglichen Suchen aufgebraucht.`,
benefit: 'Unbegrenzte Suchen mit Premium',
};
case 'collections':
return {
icon: 'folder',
title: 'Listen-Limit erreicht',
description: `Du kannst nur ${max} Liste pro Woche erstellen.`,
benefit: 'Unbegrenzte Listen mit Premium',
};
}
};
const getLimitInfo = () => {
switch (limitType) {
case 'favorites':
return {
icon: 'heart',
title: 'Favoriten-Limit erreicht',
description: `Du hast deine ${max} täglichen Favoriten aufgebraucht.`,
benefit: 'Unbegrenzte Favoriten mit Premium',
};
case 'search':
return {
icon: 'search',
title: 'Such-Limit erreicht',
description: `Du hast deine ${max} täglichen Suchen aufgebraucht.`,
benefit: 'Unbegrenzte Suchen mit Premium',
};
case 'collections':
return {
icon: 'folder',
title: 'Listen-Limit erreicht',
description: `Du kannst nur ${max} Liste pro Woche erstellen.`,
benefit: 'Unbegrenzte Listen mit Premium',
};
}
};
const info = getLimitInfo();
const info = getLimitInfo();
const handleUpgrade = () => {
onClose();
router.push('/paywall');
};
const handleUpgrade = () => {
onClose();
router.push('/paywall');
};
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View className="flex-1 bg-black/50 justify-center items-center px-6">
<TouchableWithoutFeedback>
<View
style={{
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
borderRadius: 24,
padding: 24,
width: '100%',
maxWidth: 400,
}}
>
{/* Icon */}
<View className="items-center mb-4">
<LinearGradient
colors={['#8B5CF6', '#6366F1']}
style={{
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={info.icon as any} size={28} color="white" />
</LinearGradient>
</View>
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableWithoutFeedback onPress={onClose}>
<View className="flex-1 bg-black/50 justify-center items-center px-6">
<TouchableWithoutFeedback>
<View
style={{
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
borderRadius: 24,
padding: 24,
width: '100%',
maxWidth: 400,
}}
>
{/* Icon */}
<View className="items-center mb-4">
<LinearGradient
colors={['#8B5CF6', '#6366F1']}
style={{
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={info.icon as any} size={28} color="white" />
</LinearGradient>
</View>
{/* Title */}
<Text
style={{
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
color: isDarkMode ? '#ffffff' : '#000000',
}}
>
{info.title}
</Text>
{/* Title */}
<Text
style={{
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 8,
color: isDarkMode ? '#ffffff' : '#000000',
}}
>
{info.title}
</Text>
{/* Description */}
<Text
style={{
fontSize: 16,
textAlign: 'center',
marginBottom: 16,
color: isDarkMode ? '#9ca3af' : '#4b5563',
}}
>
{info.description}
</Text>
{/* Description */}
<Text
style={{
fontSize: 16,
textAlign: 'center',
marginBottom: 16,
color: isDarkMode ? '#9ca3af' : '#4b5563',
}}
>
{info.description}
</Text>
{/* Progress Bar */}
{remaining > 0 && (
<View className="mb-4">
<View
style={{
height: 8,
backgroundColor: isDarkMode ? '#374151' : '#e5e7eb',
borderRadius: 999,
overflow: 'hidden',
}}
>
<LinearGradient
colors={['#6366F1', '#8B5CF6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{
height: '100%',
width: `${((max - remaining) / max) * 100}%`,
}}
/>
</View>
<Text
style={{
fontSize: 12,
textAlign: 'center',
marginTop: 4,
color: isDarkMode ? '#6b7280' : '#9ca3af',
}}
>
{remaining} von {max} verbleibend
</Text>
</View>
)}
{/* Progress Bar */}
{remaining > 0 && (
<View className="mb-4">
<View
style={{
height: 8,
backgroundColor: isDarkMode ? '#374151' : '#e5e7eb',
borderRadius: 999,
overflow: 'hidden',
}}
>
<LinearGradient
colors={['#6366F1', '#8B5CF6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{
height: '100%',
width: `${((max - remaining) / max) * 100}%`,
}}
/>
</View>
<Text
style={{
fontSize: 12,
textAlign: 'center',
marginTop: 4,
color: isDarkMode ? '#6b7280' : '#9ca3af',
}}
>
{remaining} von {max} verbleibend
</Text>
</View>
)}
{/* Benefit */}
<View
style={{
backgroundColor: isDarkMode ? 'rgba(99, 102, 241, 0.15)' : '#eef2ff',
borderRadius: 12,
padding: 12,
marginBottom: 24,
}}
>
<Text
style={{
color: isDarkMode ? '#a5b4fc' : '#4f46e5',
textAlign: 'center',
fontWeight: '500',
}}
>
{info.benefit}
</Text>
</View>
{/* Benefit */}
<View
style={{
backgroundColor: isDarkMode ? 'rgba(99, 102, 241, 0.15)' : '#eef2ff',
borderRadius: 12,
padding: 12,
marginBottom: 24,
}}
>
<Text
style={{
color: isDarkMode ? '#a5b4fc' : '#4f46e5',
textAlign: 'center',
fontWeight: '500',
}}
>
{info.benefit}
</Text>
</View>
{/* Buttons */}
<TouchableOpacity
onPress={handleUpgrade}
style={{
backgroundColor: '#6366F1',
borderRadius: 16,
paddingVertical: 16,
marginBottom: 12,
}}
>
<Text className="text-white text-center font-semibold text-base">
Jetzt Premium werden
</Text>
</TouchableOpacity>
{/* Buttons */}
<TouchableOpacity
onPress={handleUpgrade}
style={{
backgroundColor: '#6366F1',
borderRadius: 16,
paddingVertical: 16,
marginBottom: 12,
}}
>
<Text className="text-white text-center font-semibold text-base">
Jetzt Premium werden
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onClose}
style={{ paddingVertical: 8 }}
>
<Text
style={{
textAlign: 'center',
color: isDarkMode ? '#9ca3af' : '#6b7280',
}}
>
Später
</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}
<TouchableOpacity onPress={onClose} style={{ paddingVertical: 8 }}>
<Text
style={{
textAlign: 'center',
color: isDarkMode ? '#9ca3af' : '#6b7280',
}}
>
Später
</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
}

View file

@ -6,11 +6,7 @@ import { useIsDarkMode } from '~/store/settingsStore';
import usePremiumStore from '~/store/premiumStore';
import { PremiumLimitDialog } from './PremiumLimitDialog';
import * as Haptics from 'expo-haptics';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring
} from 'react-native-reanimated';
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
// Disable ContextMenu due to rendering issues - use ActionSheet/Alert instead
@ -20,193 +16,178 @@ let Host: any = null;
let ExpoButton: any = null;
interface QuickAddToListProps {
quoteId: string;
iconSize?: number;
iconColor?: string;
quoteId: string;
iconSize?: number;
iconColor?: string;
}
export default function QuickAddToList({
quoteId,
iconSize = 28,
iconColor = 'rgba(255,255,255,0.8)'
quoteId,
iconSize = 28,
iconColor = 'rgba(255,255,255,0.8)',
}: QuickAddToListProps) {
const isDarkMode = useIsDarkMode();
const colorScheme = useColorScheme();
const { t } = useTranslation();
const [addedLists, setAddedLists] = useState<string[]>([]);
const [showPremiumDialog, setShowPremiumDialog] = useState(false);
const iconScale = useSharedValue(1);
const isDarkMode = useIsDarkMode();
const colorScheme = useColorScheme();
const { t } = useTranslation();
const [addedLists, setAddedLists] = useState<string[]>([]);
const [showPremiumDialog, setShowPremiumDialog] = useState(false);
const iconScale = useSharedValue(1);
const {
lists,
addQuoteToList,
removeQuoteFromList,
getQuoteLists,
isQuoteInList,
initializeLists,
createList
} = useListStore();
const {
lists,
addQuoteToList,
removeQuoteFromList,
getQuoteLists,
isQuoteInList,
initializeLists,
createList,
} = useListStore();
const {
isPremium,
canCreateCollection,
getRemainingCollections,
MAX_WEEKLY_COLLECTIONS
} = usePremiumStore();
const { isPremium, canCreateCollection, getRemainingCollections, MAX_WEEKLY_COLLECTIONS } =
usePremiumStore();
useEffect(() => {
initializeLists();
}, []);
useEffect(() => {
initializeLists();
}, []);
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
iconScale.value = withSpring(1.2, {}, () => {
iconScale.value = withSpring(1);
});
// Get lists that already contain this quote
const existingLists = getQuoteLists(quoteId);
setAddedLists(existingLists.map(p => p.id));
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
iconScale.value = withSpring(1.2, {}, () => {
iconScale.value = withSpring(1);
});
// If ContextMenu is not available, use ActionSheet on iOS or Alert on Android
if (!ContextMenu && Platform.OS === 'ios') {
showIOSActionSheet();
} else if (!ContextMenu) {
showAndroidAlert();
}
};
// Get lists that already contain this quote
const existingLists = getQuoteLists(quoteId);
setAddedLists(existingLists.map((p) => p.id));
const showIOSActionSheet = () => {
const options = [
...lists.map(p => {
const isInList = isQuoteInList(p.id, quoteId);
return isInList ? `${p.name}` : p.name;
}),
t('lists.createNew'),
t('common.cancel')
];
// If ContextMenu is not available, use ActionSheet on iOS or Alert on Android
if (!ContextMenu && Platform.OS === 'ios') {
showIOSActionSheet();
} else if (!ContextMenu) {
showAndroidAlert();
}
};
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: options.length - 1,
title: t('lists.addToList')
},
(buttonIndex) => {
if (buttonIndex < lists.length) {
handleToggleList(lists[buttonIndex]);
} else if (buttonIndex === lists.length) {
handleCreateNewList();
}
}
);
};
const showIOSActionSheet = () => {
const options = [
...lists.map((p) => {
const isInList = isQuoteInList(p.id, quoteId);
return isInList ? `${p.name}` : p.name;
}),
t('lists.createNew'),
t('common.cancel'),
];
const showAndroidAlert = () => {
Alert.alert(
t('lists.addToList'),
t('lists.chooseOrCreate'),
[
...lists.map(p => ({
text: isQuoteInList(p.id, quoteId) ? `${p.name}` : p.name,
onPress: () => handleToggleList(p)
})),
{
text: t('lists.createNew'),
onPress: handleCreateNewList
},
{
text: t('common.cancel'),
style: 'cancel'
}
]
);
};
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: options.length - 1,
title: t('lists.addToList'),
},
(buttonIndex) => {
if (buttonIndex < lists.length) {
handleToggleList(lists[buttonIndex]);
} else if (buttonIndex === lists.length) {
handleCreateNewList();
}
}
);
};
const handleToggleList = (list: List) => {
if (isQuoteInList(list.id, quoteId)) {
// Remove from list
removeQuoteFromList(list.id, quoteId);
setAddedLists(addedLists.filter(id => id !== list.id));
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
// Add to list
addQuoteToList(list.id, quoteId);
setAddedLists([...addedLists, list.id]);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
// Context menu closes automatically after selection
};
const showAndroidAlert = () => {
Alert.alert(t('lists.addToList'), t('lists.chooseOrCreate'), [
...lists.map((p) => ({
text: isQuoteInList(p.id, quoteId) ? `${p.name}` : p.name,
onPress: () => handleToggleList(p),
})),
{
text: t('lists.createNew'),
onPress: handleCreateNewList,
},
{
text: t('common.cancel'),
style: 'cancel',
},
]);
};
const handleCreateNewList = () => {
// Check premium limits before allowing creation
if (!isPremium && !canCreateCollection()) {
setShowPremiumDialog(true);
return;
}
const handleToggleList = (list: List) => {
if (isQuoteInList(list.id, quoteId)) {
// Remove from list
removeQuoteFromList(list.id, quoteId);
setAddedLists(addedLists.filter((id) => id !== list.id));
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
// Add to list
addQuoteToList(list.id, quoteId);
setAddedLists([...addedLists, list.id]);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
Alert.prompt(
t('lists.newList'),
t('lists.newListPrompt'),
[
{
text: t('common.cancel'),
style: 'cancel'
},
{
text: t('lists.create'),
onPress: (name) => {
if (name && name.trim()) {
const newListId = createList(name.trim());
// Context menu closes automatically after selection
};
// Check if creation was successful (returns empty string if limit reached)
if (newListId) {
// Add the current quote to the new list
addQuoteToList(newListId, quoteId);
setAddedLists([...addedLists, newListId]);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
// Show premium dialog if creation failed due to limits
setShowPremiumDialog(true);
}
}
}
}
],
'plain-text',
t('lists.newListPlaceholder')
);
};
const handleCreateNewList = () => {
// Check premium limits before allowing creation
if (!isPremium && !canCreateCollection()) {
setShowPremiumDialog(true);
return;
}
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: iconScale.value }]
}));
Alert.prompt(
t('lists.newList'),
t('lists.newListPrompt'),
[
{
text: t('common.cancel'),
style: 'cancel',
},
{
text: t('lists.create'),
onPress: (name) => {
if (name && name.trim()) {
const newListId = createList(name.trim());
// Use ActionSheet/Alert for reliable rendering
// ContextMenu (@expo/ui) was causing the icon to disappear intermittently
return (
<>
<Pressable
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Animated.View style={iconAnimatedStyle}>
<Icon
name="add-outline"
size={iconSize}
color={iconColor}
/>
</Animated.View>
</Pressable>
// Check if creation was successful (returns empty string if limit reached)
if (newListId) {
// Add the current quote to the new list
addQuoteToList(newListId, quoteId);
setAddedLists([...addedLists, newListId]);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
// Show premium dialog if creation failed due to limits
setShowPremiumDialog(true);
}
}
},
},
],
'plain-text',
t('lists.newListPlaceholder')
);
};
<PremiumLimitDialog
visible={showPremiumDialog}
onClose={() => setShowPremiumDialog(false)}
limitType="collections"
remaining={getRemainingCollections()}
max={MAX_WEEKLY_COLLECTIONS}
/>
</>
);
}
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: iconScale.value }],
}));
// Use ActionSheet/Alert for reliable rendering
// ContextMenu (@expo/ui) was causing the icon to disappear intermittently
return (
<>
<Pressable onPress={handlePress} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Animated.View style={iconAnimatedStyle}>
<Icon name="add-outline" size={iconSize} color={iconColor} />
</Animated.View>
</Pressable>
<PremiumLimitDialog
visible={showPremiumDialog}
onClose={() => setShowPremiumDialog(false)}
limitType="collections"
remaining={getRemainingCollections()}
max={MAX_WEEKLY_COLLECTIONS}
/>
</>
);
}

View file

@ -5,14 +5,14 @@ import { LinearGradient } from 'expo-linear-gradient';
import { Icon } from './Icon';
import * as Haptics from 'expo-haptics';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
FadeInUp,
FadeInDown,
interpolate,
Extrapolate,
SharedValue
useAnimatedStyle,
useSharedValue,
withSpring,
FadeInUp,
FadeInDown,
interpolate,
Extrapolate,
SharedValue,
} from 'react-native-reanimated';
import type { EnhancedQuote } from '@quote/shared';
import { useTheme } from '~/hooks/useTheme';
@ -24,409 +24,367 @@ import FavoriteButton from './common/FavoriteButton';
export type QuoteCardVariant = 'simple' | 'daily' | 'vertical' | 'author-detail' | 'edit';
interface QuoteCardProps {
quote: EnhancedQuote;
onToggleFavorite: (id: string) => void;
onAuthorPress?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onPress?: () => void;
variant?: QuoteCardVariant;
// For vertical variant
index?: number;
scrollY?: SharedValue<number>;
cardHeight?: number;
// Show date instead of author (for author detail pages)
showDate?: boolean;
// For edit mode
editMode?: boolean;
onTextChange?: (text: string) => void;
onAuthorChange?: (author: string) => void;
onCategoryChange?: (categories: string) => void;
onSave?: () => void;
onCancel?: () => void;
quote: EnhancedQuote;
onToggleFavorite: (id: string) => void;
onAuthorPress?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onPress?: () => void;
variant?: QuoteCardVariant;
// For vertical variant
index?: number;
scrollY?: SharedValue<number>;
cardHeight?: number;
// Show date instead of author (for author detail pages)
showDate?: boolean;
// For edit mode
editMode?: boolean;
onTextChange?: (text: string) => void;
onAuthorChange?: (author: string) => void;
onCategoryChange?: (categories: string) => void;
onSave?: () => void;
onCancel?: () => void;
}
function QuoteCard({
quote,
onToggleFavorite,
onAuthorPress,
onEdit,
onDelete,
onPress,
variant = 'simple',
index = 0,
scrollY,
cardHeight = 300,
showDate = false,
editMode = false,
onTextChange,
onAuthorChange,
onCategoryChange,
onSave,
onCancel
quote,
onToggleFavorite,
onAuthorPress,
onEdit,
onDelete,
onPress,
variant = 'simple',
index = 0,
scrollY,
cardHeight = 300,
showDate = false,
editMode = false,
onTextChange,
onAuthorChange,
onCategoryChange,
onSave,
onCancel,
}: QuoteCardProps) {
const { t } = useTranslation();
const scale = useSharedValue(1);
const { shareQuote, copyQuoteToClipboard } = useShare();
const { t } = useTranslation();
const scale = useSharedValue(1);
const { shareQuote, copyQuoteToClipboard } = useShare();
// Safety check: return null if quote is invalid
if (!quote || !quote.id || !quote.text) {
console.warn('[QuoteCard] Attempted to render invalid quote:', quote);
return null;
}
// Safety check: return null if quote is invalid
if (!quote || !quote.id || !quote.text) {
console.warn('[QuoteCard] Attempted to render invalid quote:', quote);
return null;
}
const handleFavorite = () => {
if (!quote || !quote.id) {
console.warn('[QuoteCard] Attempted to toggle favorite on invalid quote:', quote);
return;
}
onToggleFavorite(quote.id);
};
const handleFavorite = () => {
if (!quote || !quote.id) {
console.warn('[QuoteCard] Attempted to toggle favorite on invalid quote:', quote);
return;
}
onToggleFavorite(quote.id);
};
const handlePress = () => {
if (onPress) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
scale.value = withSpring(0.95, {}, () => {
scale.value = withSpring(1);
});
onPress();
}
};
const handlePress = () => {
if (onPress) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
scale.value = withSpring(0.95, {}, () => {
scale.value = withSpring(1);
});
onPress();
}
};
const handleShare = () => shareQuote(quote);
const handleCopyToClipboard = () => copyQuoteToClipboard(quote);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }]
}));
const verticalAnimatedStyle = useAnimatedStyle(() => {
if (variant !== 'vertical' || !scrollY) {
return {};
}
const cardPosition = index * cardHeight;
const inputRange = [
cardPosition - cardHeight,
cardPosition,
cardPosition + cardHeight,
];
const opacity = interpolate(
scrollY.value,
inputRange,
[0.3, 1, 0.3],
Extrapolate.CLAMP
);
const scale = interpolate(
scrollY.value,
inputRange,
[0.85, 1, 0.85],
Extrapolate.CLAMP
);
return {
opacity,
transform: [{ scale }],
};
});
const { getCategoryGradient, getDailyCardGradient } = useTheme();
const getGradientColors = () => {
if (variant === 'daily') {
return getDailyCardGradient();
}
const category = quote.categories?.[0];
return getCategoryGradient(category);
};
const handleShare = () => shareQuote(quote);
const handleCopyToClipboard = () => copyQuoteToClipboard(quote);
const getQuoteMetadata = () => {
if (quote.year && quote.source) {
return `${quote.source} (${quote.year})`;
} else if (quote.year) {
return quote.year.toString();
} else if (quote.source) {
return quote.source;
}
return null;
};
const isDaily = variant === 'daily';
const isVertical = variant === 'vertical';
const cardContent = (
<LinearGradient
colors={getGradientColors()}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
borderRadius: isDaily ? 32 : 24,
padding: 1
}}
>
<View className={`${isDaily ? 'bg-black/30 rounded-[31px]' : 'bg-black/40 rounded-3xl'} backdrop-blur-xl`}>
<View className={isDaily ? 'p-5' : 'p-6'}>
{/* Quote Text */}
{editMode ? (
<TextInput
value={quote.text}
onChangeText={onTextChange}
placeholder={t('myQuotes.placeholder')}
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
style={{
fontFamily: 'Georgia',
fontSize: 22,
lineHeight: 32,
color: 'white',
fontWeight: '300',
letterSpacing: 0.3,
minHeight: 80,
textAlignVertical: 'top',
paddingVertical: 0
}}
className="mb-4"
autoFocus
/>
) : (
<Text
style={{
fontFamily: 'Georgia',
fontSize: isDaily ? 24 : 22,
lineHeight: isDaily ? 34 : 32,
color: 'white',
fontWeight: '300',
letterSpacing: 0.3
}}
className={isDaily ? 'mb-4' : 'mb-4'}
>
"{quote.text}"
</Text>
)}
{/* Source Info wenn vorhanden */}
{!isDaily && quote.source && (
<Text
variant="caption"
className="mb-3"
style={{ color: 'rgba(255,255,255,0.6)' }}
>
{t('quotes.from')}: {quote.source} {quote.year && `(${quote.year})`}
</Text>
)}
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
{/* Author mit Divider */}
<View className={`border-t border-white/10 ${isDaily ? 'pt-4' : 'pt-3'}`}>
<Pressable onPress={onAuthorPress}>
<View className="flex-row items-center justify-between">
<View>
{editMode ? (
<>
<TextInput
value={quote.author?.name || ''}
onChangeText={onAuthorChange}
placeholder="Autor"
placeholderTextColor="rgba(255,255,255,0.4)"
style={{
fontSize: 16,
color: 'white',
fontWeight: '500',
paddingVertical: 2
}}
/>
<TextInput
value={quote.categories?.join(', ') || ''}
onChangeText={onCategoryChange}
placeholder="Kategorien (kommagetrennt)"
placeholderTextColor="rgba(255,255,255,0.3)"
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.6)',
paddingVertical: 2,
marginTop: 2
}}
/>
</>
) : showDate ? (
<></>
) : (
<>
<Text
variant="body"
weight="medium"
style={{ color: 'white' }}
>
{quote.author?.name || t('quotes.unknown')}
</Text>
{quote.author?.profession && (
<Text
variant="caption"
className="mt-0.5"
style={{ color: 'rgba(255,255,255,0.6)' }}
>
{quote.author.profession[0]}
</Text>
)}
</>
)}
</View>
{/* Action Buttons */}
<View className="flex-row items-center gap-3">
{editMode ? (
<>
{/* Cancel Button */}
<Pressable
onPress={onCancel}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="close"
size={22}
color="rgba(255,255,255,0.8)"
/>
</Pressable>
{/* Save Button */}
<Pressable
onPress={onSave}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
disabled={!quote.text?.trim()}
>
<Icon
name="checkmark"
size={22}
color={quote.text?.trim() ? "rgba(255,255,255,0.8)" : "rgba(255,255,255,0.3)"}
/>
</Pressable>
</>
) : (
<>
{/* Copy Button */}
<Pressable
onPress={handleCopyToClipboard}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="copy-outline"
size={22}
color="rgba(255,255,255,0.7)"
/>
</Pressable>
const verticalAnimatedStyle = useAnimatedStyle(() => {
if (variant !== 'vertical' || !scrollY) {
return {};
}
{/* Share Button */}
<Pressable
onPress={handleShare}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="share-outline"
size={22}
color="rgba(255,255,255,0.7)"
/>
</Pressable>
const cardPosition = index * cardHeight;
const inputRange = [cardPosition - cardHeight, cardPosition, cardPosition + cardHeight];
{/* Edit Button (only if onEdit is provided) */}
{!isDaily && onEdit && (
<Pressable
onPress={onEdit}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="create-outline"
size={22}
color="rgba(255,255,255,0.7)"
/>
</Pressable>
)}
{/* Delete Button (only if onDelete is provided) */}
{!isDaily && onDelete && (
<Pressable
onPress={onDelete}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon
name="trash-outline"
size={22}
color="rgba(255,255,255,0.7)"
/>
</Pressable>
)}
{/* Add to List Button */}
<QuickAddToList
quoteId={quote.id}
iconSize={23}
iconColor="rgba(255,255,255,0.75)"
/>
{/* Favorite Button */}
<FavoriteButton
isFavorite={quote.isFavorite}
onToggle={handleFavorite}
size={24}
variant={isDaily ? 'daily' : 'default'}
/>
</>
)}
</View>
</View>
</Pressable>
</View>
</View>
</View>
</LinearGradient>
);
if (isVertical) {
return (
<View style={{
height: cardHeight,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
}}>
<View style={{ width: '100%', maxWidth: 400, marginTop: -40 }}>
<Animated.View style={verticalAnimatedStyle}>
<Animated.View style={animatedStyle}>
{cardContent}
</Animated.View>
</Animated.View>
</View>
</View>
);
}
return (
<Animated.View
entering={isDaily ? FadeInDown.duration(600).springify() : FadeInUp.duration(600).springify()}
className="mx-4"
>
<Animated.View style={animatedStyle}>
{isDaily && onPress ? (
<Pressable onPress={handlePress}>
{cardContent}
</Pressable>
) : (
cardContent
)}
</Animated.View>
</Animated.View>
);
const opacity = interpolate(scrollY.value, inputRange, [0.3, 1, 0.3], Extrapolate.CLAMP);
const scale = interpolate(scrollY.value, inputRange, [0.85, 1, 0.85], Extrapolate.CLAMP);
return {
opacity,
transform: [{ scale }],
};
});
const { getCategoryGradient, getDailyCardGradient } = useTheme();
const getGradientColors = () => {
if (variant === 'daily') {
return getDailyCardGradient();
}
const category = quote.categories?.[0];
return getCategoryGradient(category);
};
const getQuoteMetadata = () => {
if (quote.year && quote.source) {
return `${quote.source} (${quote.year})`;
} else if (quote.year) {
return quote.year.toString();
} else if (quote.source) {
return quote.source;
}
return null;
};
const isDaily = variant === 'daily';
const isVertical = variant === 'vertical';
const cardContent = (
<LinearGradient
colors={getGradientColors()}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
borderRadius: isDaily ? 32 : 24,
padding: 1,
}}
>
<View
className={`${isDaily ? 'bg-black/30 rounded-[31px]' : 'bg-black/40 rounded-3xl'} backdrop-blur-xl`}
>
<View className={isDaily ? 'p-5' : 'p-6'}>
{/* Quote Text */}
{editMode ? (
<TextInput
value={quote.text}
onChangeText={onTextChange}
placeholder={t('myQuotes.placeholder')}
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
style={{
fontFamily: 'Georgia',
fontSize: 22,
lineHeight: 32,
color: 'white',
fontWeight: '300',
letterSpacing: 0.3,
minHeight: 80,
textAlignVertical: 'top',
paddingVertical: 0,
}}
className="mb-4"
autoFocus
/>
) : (
<Text
style={{
fontFamily: 'Georgia',
fontSize: isDaily ? 24 : 22,
lineHeight: isDaily ? 34 : 32,
color: 'white',
fontWeight: '300',
letterSpacing: 0.3,
}}
className={isDaily ? 'mb-4' : 'mb-4'}
>
"{quote.text}"
</Text>
)}
{/* Source Info wenn vorhanden */}
{!isDaily && quote.source && (
<Text variant="caption" className="mb-3" style={{ color: 'rgba(255,255,255,0.6)' }}>
{t('quotes.from')}: {quote.source} {quote.year && `(${quote.year})`}
</Text>
)}
{/* Author mit Divider */}
<View className={`border-t border-white/10 ${isDaily ? 'pt-4' : 'pt-3'}`}>
<Pressable onPress={onAuthorPress}>
<View className="flex-row items-center justify-between">
<View>
{editMode ? (
<>
<TextInput
value={quote.author?.name || ''}
onChangeText={onAuthorChange}
placeholder="Autor"
placeholderTextColor="rgba(255,255,255,0.4)"
style={{
fontSize: 16,
color: 'white',
fontWeight: '500',
paddingVertical: 2,
}}
/>
<TextInput
value={quote.categories?.join(', ') || ''}
onChangeText={onCategoryChange}
placeholder="Kategorien (kommagetrennt)"
placeholderTextColor="rgba(255,255,255,0.3)"
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.6)',
paddingVertical: 2,
marginTop: 2,
}}
/>
</>
) : showDate ? (
<></>
) : (
<>
<Text variant="body" weight="medium" style={{ color: 'white' }}>
{quote.author?.name || t('quotes.unknown')}
</Text>
{quote.author?.profession && (
<Text
variant="caption"
className="mt-0.5"
style={{ color: 'rgba(255,255,255,0.6)' }}
>
{quote.author.profession[0]}
</Text>
)}
</>
)}
</View>
{/* Action Buttons */}
<View className="flex-row items-center gap-3">
{editMode ? (
<>
{/* Cancel Button */}
<Pressable
onPress={onCancel}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="close" size={22} color="rgba(255,255,255,0.8)" />
</Pressable>
{/* Save Button */}
<Pressable
onPress={onSave}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
disabled={!quote.text?.trim()}
>
<Icon
name="checkmark"
size={22}
color={
quote.text?.trim() ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.3)'
}
/>
</Pressable>
</>
) : (
<>
{/* Copy Button */}
<Pressable
onPress={handleCopyToClipboard}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="copy-outline" size={22} color="rgba(255,255,255,0.7)" />
</Pressable>
{/* Share Button */}
<Pressable
onPress={handleShare}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="share-outline" size={22} color="rgba(255,255,255,0.7)" />
</Pressable>
{/* Edit Button (only if onEdit is provided) */}
{!isDaily && onEdit && (
<Pressable
onPress={onEdit}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="create-outline" size={22} color="rgba(255,255,255,0.7)" />
</Pressable>
)}
{/* Delete Button (only if onDelete is provided) */}
{!isDaily && onDelete && (
<Pressable
onPress={onDelete}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Icon name="trash-outline" size={22} color="rgba(255,255,255,0.7)" />
</Pressable>
)}
{/* Add to List Button */}
<QuickAddToList
quoteId={quote.id}
iconSize={23}
iconColor="rgba(255,255,255,0.75)"
/>
{/* Favorite Button */}
<FavoriteButton
isFavorite={quote.isFavorite}
onToggle={handleFavorite}
size={24}
variant={isDaily ? 'daily' : 'default'}
/>
</>
)}
</View>
</View>
</Pressable>
</View>
</View>
</View>
</LinearGradient>
);
if (isVertical) {
return (
<View
style={{
height: cardHeight,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
}}
>
<View style={{ width: '100%', maxWidth: 400, marginTop: -40 }}>
<Animated.View style={verticalAnimatedStyle}>
<Animated.View style={animatedStyle}>{cardContent}</Animated.View>
</Animated.View>
</View>
</View>
);
}
return (
<Animated.View
entering={isDaily ? FadeInDown.duration(600).springify() : FadeInUp.duration(600).springify()}
className="mx-4"
>
<Animated.View style={animatedStyle}>
{isDaily && onPress ? (
<Pressable onPress={handlePress}>{cardContent}</Pressable>
) : (
cardContent
)}
</Animated.View>
</Animated.View>
);
}
// Optimierung mit React.memo
export default React.memo(QuoteCard, (prevProps, nextProps) => {
// Nur re-rendern wenn sich relevante Props ändern
return (
prevProps.quote.id === nextProps.quote.id &&
prevProps.quote.isFavorite === nextProps.quote.isFavorite &&
prevProps.quote.text === nextProps.quote.text &&
prevProps.variant === nextProps.variant
);
});
// Nur re-rendern wenn sich relevante Props ändern
return (
prevProps.quote.id === nextProps.quote.id &&
prevProps.quote.isFavorite === nextProps.quote.isFavorite &&
prevProps.quote.text === nextProps.quote.text &&
prevProps.variant === nextProps.variant
);
});

View file

@ -2,154 +2,150 @@ import React from 'react';
import { Text as RNText, TextProps as RNTextProps, TextStyle } from 'react-native';
import { useIsDarkMode, useFontSize } from '~/store/settingsStore';
type TextVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'body'
| 'bodyLarge'
| 'bodySmall'
| 'caption'
| 'label'
| 'button'
| 'quote';
type TextVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'body'
| 'bodyLarge'
| 'bodySmall'
| 'caption'
| 'label'
| 'button'
| 'quote';
type TextColor =
| 'primary'
| 'secondary'
| 'tertiary'
| 'accent'
| 'danger'
| 'success'
| 'warning'
| 'inherit';
type TextColor =
| 'primary'
| 'secondary'
| 'tertiary'
| 'accent'
| 'danger'
| 'success'
| 'warning'
| 'inherit';
interface TextProps extends Omit<RNTextProps, 'style'> {
variant?: TextVariant;
color?: TextColor;
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
align?: 'left' | 'center' | 'right' | 'justify';
className?: string;
style?: TextStyle;
children?: React.ReactNode;
variant?: TextVariant;
color?: TextColor;
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
align?: 'left' | 'center' | 'right' | 'justify';
className?: string;
style?: TextStyle;
children?: React.ReactNode;
}
export default function Text({
variant = 'body',
color = 'primary',
weight,
align,
className = '',
style,
children,
...props
variant = 'body',
color = 'primary',
weight,
align,
className = '',
style,
children,
...props
}: TextProps) {
const isDarkMode = useIsDarkMode();
const fontSize = useFontSize();
const isDarkMode = useIsDarkMode();
const fontSize = useFontSize();
// Font size multiplier based on user preference
const sizeMultiplier = fontSize === 'small' ? 0.9 : fontSize === 'large' ? 1.1 : 1;
// Font size multiplier based on user preference
const sizeMultiplier = fontSize === 'small' ? 0.9 : fontSize === 'large' ? 1.1 : 1;
// Base styles for each variant
const variantStyles: Record<TextVariant, string> = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-bold',
h3: 'text-2xl font-semibold',
h4: 'text-xl font-semibold',
body: 'text-base',
bodyLarge: 'text-lg',
bodySmall: 'text-sm',
caption: 'text-xs',
label: 'text-sm font-medium uppercase tracking-wider',
button: 'text-base font-semibold',
quote: 'text-lg italic',
};
// Base styles for each variant
const variantStyles: Record<TextVariant, string> = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-bold',
h3: 'text-2xl font-semibold',
h4: 'text-xl font-semibold',
body: 'text-base',
bodyLarge: 'text-lg',
bodySmall: 'text-sm',
caption: 'text-xs',
label: 'text-sm font-medium uppercase tracking-wider',
button: 'text-base font-semibold',
quote: 'text-lg italic',
};
// Color styles based on theme
const getColorClass = (color: TextColor): string => {
if (color === 'inherit') return '';
const colors: Record<TextColor, { dark: string; light: string }> = {
primary: {
dark: 'text-white',
light: 'text-black',
},
secondary: {
dark: 'text-white/60',
light: 'text-black/60',
},
tertiary: {
dark: 'text-white/40',
light: 'text-black/40',
},
accent: {
dark: 'text-purple-400',
light: 'text-purple-600',
},
danger: {
dark: 'text-red-400',
light: 'text-red-600',
},
success: {
dark: 'text-green-400',
light: 'text-green-600',
},
warning: {
dark: 'text-yellow-400',
light: 'text-yellow-600',
},
inherit: {
dark: '',
light: '',
},
};
// Color styles based on theme
const getColorClass = (color: TextColor): string => {
if (color === 'inherit') return '';
return isDarkMode ? colors[color].dark : colors[color].light;
};
const colors: Record<TextColor, { dark: string; light: string }> = {
primary: {
dark: 'text-white',
light: 'text-black',
},
secondary: {
dark: 'text-white/60',
light: 'text-black/60',
},
tertiary: {
dark: 'text-white/40',
light: 'text-black/40',
},
accent: {
dark: 'text-purple-400',
light: 'text-purple-600',
},
danger: {
dark: 'text-red-400',
light: 'text-red-600',
},
success: {
dark: 'text-green-400',
light: 'text-green-600',
},
warning: {
dark: 'text-yellow-400',
light: 'text-yellow-600',
},
inherit: {
dark: '',
light: '',
},
};
// Weight styles
const weightStyles: Record<string, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
return isDarkMode ? colors[color].dark : colors[color].light;
};
// Alignment styles
const alignStyles: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
// Weight styles
const weightStyles: Record<string, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
// Combine all classes
const combinedClassName = [
variantStyles[variant],
getColorClass(color),
weight ? weightStyles[weight] : '',
align ? alignStyles[align] : '',
className,
]
.filter(Boolean)
.join(' ');
// Alignment styles
const alignStyles: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
// Apply size multiplier to style
const combinedStyle: TextStyle = {
...(style || {}),
...(sizeMultiplier !== 1 ? { fontSize: (style?.fontSize || 16) * sizeMultiplier } : {}),
};
// Combine all classes
const combinedClassName = [
variantStyles[variant],
getColorClass(color),
weight ? weightStyles[weight] : '',
align ? alignStyles[align] : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<RNText
className={combinedClassName}
style={combinedStyle}
{...props}
>
{children}
</RNText>
);
// Apply size multiplier to style
const combinedStyle: TextStyle = {
...(style || {}),
...(sizeMultiplier !== 1 ? { fontSize: (style?.fontSize || 16) * sizeMultiplier } : {}),
};
return (
<RNText className={combinedClassName} style={combinedStyle} {...props}>
{children}
</RNText>
);
}
// Export convenience components for common use cases
@ -160,4 +156,4 @@ export const H4 = (props: Omit<TextProps, 'variant'>) => <Text variant="h4" {...
export const Body = (props: Omit<TextProps, 'variant'>) => <Text variant="body" {...props} />;
export const Caption = (props: Omit<TextProps, 'variant'>) => <Text variant="caption" {...props} />;
export const Label = (props: Omit<TextProps, 'variant'>) => <Text variant="label" {...props} />;
export const Quote = (props: Omit<TextProps, 'variant'>) => <Text variant="quote" {...props} />;
export const Quote = (props: Omit<TextProps, 'variant'>) => <Text variant="quote" {...props} />;

View file

@ -8,157 +8,141 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { AuthorFilters } from './AuthorFilterSheet';
interface ActiveFilterChipsProps {
filters: AuthorFilters;
onRemoveFilter: (category: keyof AuthorFilters, value: string) => void;
onClearAll: () => void;
filters: AuthorFilters;
onRemoveFilter: (category: keyof AuthorFilters, value: string) => void;
onClearAll: () => void;
}
const FILTER_LABELS: Record<string, string> = {
// Epochs
ancient: 'Antike',
medieval: 'Mittelalter',
earlyModern: 'Frühe Neuzeit',
'19th': '19. Jhd.',
'20th': '20. Jhd.',
'21st': '21. Jhd.',
living: 'Lebend',
// Epochs
ancient: 'Antike',
medieval: 'Mittelalter',
earlyModern: 'Frühe Neuzeit',
'19th': '19. Jhd.',
'20th': '20. Jhd.',
'21st': '21. Jhd.',
living: 'Lebend',
// Professions
philosopher: 'Philosoph',
writer: 'Schriftsteller',
scientist: 'Wissenschaftler',
politician: 'Politiker',
artist: 'Künstler',
entrepreneur: 'Unternehmer',
poet: 'Dichter',
activist: 'Aktivist',
// Professions
philosopher: 'Philosoph',
writer: 'Schriftsteller',
scientist: 'Wissenschaftler',
politician: 'Politiker',
artist: 'Künstler',
entrepreneur: 'Unternehmer',
poet: 'Dichter',
activist: 'Aktivist',
// Nationalities
german: 'Deutsch',
american: 'Amerikanisch',
british: 'Britisch',
french: 'Französisch',
italian: 'Italienisch',
spanish: 'Spanisch',
greek: 'Griechisch',
roman: 'Römisch',
// Nationalities
german: 'Deutsch',
american: 'Amerikanisch',
british: 'Britisch',
french: 'Französisch',
italian: 'Italienisch',
spanish: 'Spanisch',
greek: 'Griechisch',
roman: 'Römisch',
// Quote counts
few: '1-5 Zitate',
medium: '6-15 Zitate',
many: '16-50 Zitate',
verymany: '50+ Zitate',
// Quote counts
few: '1-5 Zitate',
medium: '6-15 Zitate',
many: '16-50 Zitate',
verymany: '50+ Zitate',
// Special
verified: 'Verifiziert',
featured: 'Featured',
hasImage: 'Mit Bild',
hasBio: 'Mit Bio',
// Special
verified: 'Verifiziert',
featured: 'Featured',
hasImage: 'Mit Bild',
hasBio: 'Mit Bio',
};
export function ActiveFilterChips({
filters,
onRemoveFilter,
onClearAll
}: ActiveFilterChipsProps) {
const isDarkMode = useIsDarkMode();
export function ActiveFilterChips({ filters, onRemoveFilter, onClearAll }: ActiveFilterChipsProps) {
const isDarkMode = useIsDarkMode();
const allActiveFilters: { category: keyof AuthorFilters; value: string; label: string }[] = [];
const allActiveFilters: { category: keyof AuthorFilters; value: string; label: string }[] = [];
// Collect all active filters
(Object.keys(filters) as Array<keyof AuthorFilters>).forEach(category => {
filters[category].forEach(value => {
allActiveFilters.push({
category,
value,
label: FILTER_LABELS[value] || value
});
});
});
// Collect all active filters
(Object.keys(filters) as Array<keyof AuthorFilters>).forEach((category) => {
filters[category].forEach((value) => {
allActiveFilters.push({
category,
value,
label: FILTER_LABELS[value] || value,
});
});
});
if (allActiveFilters.length === 0) return null;
if (allActiveFilters.length === 0) return null;
return (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={{
paddingHorizontal: 16,
paddingTop: 110, // Account for header
paddingBottom: 8,
backgroundColor: isDarkMode ? '#000' : '#fff',
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
{/* Active filter chips */}
{allActiveFilters.map((filter, index) => (
<Pressable
key={`${filter.category}-${filter.value}-${index}`}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onRemoveFilter(filter.category, filter.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 12,
paddingRight: 8,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: 'rgba(124, 58, 237, 0.15)',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.3)',
}}
>
<Text
variant="caption"
weight="medium"
style={{ color: '#7c3aed', marginRight: 4 }}
>
{filter.label}
</Text>
<Icon
name="close-circle"
size={16}
color="#7c3aed"
/>
</Pressable>
))}
return (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={{
paddingHorizontal: 16,
paddingTop: 110, // Account for header
paddingBottom: 8,
backgroundColor: isDarkMode ? '#000' : '#fff',
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
{/* Active filter chips */}
{allActiveFilters.map((filter, index) => (
<Pressable
key={`${filter.category}-${filter.value}-${index}`}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onRemoveFilter(filter.category, filter.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 12,
paddingRight: 8,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: 'rgba(124, 58, 237, 0.15)',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.3)',
}}
>
<Text variant="caption" weight="medium" style={{ color: '#7c3aed', marginRight: 4 }}>
{filter.label}
</Text>
<Icon name="close-circle" size={16} color="#7c3aed" />
</Pressable>
))}
{/* Clear all button */}
{allActiveFilters.length > 1 && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)',
borderWidth: 1,
borderColor: isDarkMode ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)',
}}
>
<Text
variant="caption"
weight="semibold"
style={{ color: '#ef4444' }}
>
Alle löschen
</Text>
</Pressable>
)}
</ScrollView>
</Animated.View>
);
{/* Clear all button */}
{allActiveFilters.length > 1 && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)',
borderWidth: 1,
borderColor: isDarkMode ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)',
}}
>
<Text variant="caption" weight="semibold" style={{ color: '#ef4444' }}>
Alle löschen
</Text>
</Pressable>
)}
</ScrollView>
</Animated.View>
);
}

View file

@ -2,53 +2,50 @@ import React from 'react';
import { View, Text, Image } from 'react-native';
interface AuthorAvatarProps {
name: string;
imageUrl?: string;
size?: 'small' | 'medium' | 'large';
className?: string;
name: string;
imageUrl?: string;
size?: 'small' | 'medium' | 'large';
className?: string;
}
export const AuthorAvatar: React.FC<AuthorAvatarProps> = ({
name,
imageUrl,
size = 'medium',
className = '',
name,
imageUrl,
size = 'medium',
className = '',
}) => {
const getInitials = (fullName: string): string => {
const parts = fullName.split(' ');
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return fullName.substring(0, 2).toUpperCase();
};
const getInitials = (fullName: string): string => {
const parts = fullName.split(' ');
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return fullName.substring(0, 2).toUpperCase();
};
const sizeStyles = {
small: { width: 48, height: 48, fontSize: 18 },
medium: { width: 64, height: 64, fontSize: 20 },
large: { width: 128, height: 128, fontSize: 36 },
};
const sizeStyles = {
small: { width: 48, height: 48, fontSize: 18 },
medium: { width: 64, height: 64, fontSize: 20 },
large: { width: 128, height: 128, fontSize: 36 },
};
const style = sizeStyles[size];
const style = sizeStyles[size];
return (
<View
className={`bg-gray-800 items-center justify-center rounded-full ${className}`}
style={{ width: style.width, height: style.height }}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
className="rounded-full"
style={{ width: style.width, height: style.height }}
/>
) : (
<Text
className="text-white font-bold"
style={{ fontSize: style.fontSize }}
>
{getInitials(name)}
</Text>
)}
</View>
);
};
return (
<View
className={`bg-gray-800 items-center justify-center rounded-full ${className}`}
style={{ width: style.width, height: style.height }}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
className="rounded-full"
style={{ width: style.width, height: style.height }}
/>
) : (
<Text className="text-white font-bold" style={{ fontSize: style.fontSize }}>
{getInitials(name)}
</Text>
)}
</View>
);
};

View file

@ -9,288 +9,281 @@ import { useTranslation } from 'react-i18next';
import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet';
export interface AuthorFilters {
epochs: string[];
professions: string[];
nationalities: string[];
quoteCount: string[];
special: string[];
epochs: string[];
professions: string[];
nationalities: string[];
quoteCount: string[];
special: string[];
}
interface AuthorFilterBottomSheetProps {
bottomSheetRef: React.RefObject<BottomSheet>;
filters: AuthorFilters;
onFiltersChange: (filters: AuthorFilters) => void;
onClearAll: () => void;
bottomSheetRef: React.RefObject<BottomSheet>;
filters: AuthorFilters;
onFiltersChange: (filters: AuthorFilters) => void;
onClearAll: () => void;
}
const EPOCH_OPTIONS = [
{ key: 'ancient', label: 'Antike', description: 'Vor 500' },
{ key: 'medieval', label: 'Mittelalter', description: '500-1500' },
{ key: 'earlyModern', label: 'Frühe Neuzeit', description: '1500-1800' },
{ key: '19th', label: '19. Jahrhundert', description: '1800-1900' },
{ key: '20th', label: '20. Jahrhundert', description: '1900-2000' },
{ key: '21st', label: '21. Jahrhundert', description: '2000+' },
{ key: 'living', label: 'Noch lebend', description: '' },
{ key: 'ancient', label: 'Antike', description: 'Vor 500' },
{ key: 'medieval', label: 'Mittelalter', description: '500-1500' },
{ key: 'earlyModern', label: 'Frühe Neuzeit', description: '1500-1800' },
{ key: '19th', label: '19. Jahrhundert', description: '1800-1900' },
{ key: '20th', label: '20. Jahrhundert', description: '1900-2000' },
{ key: '21st', label: '21. Jahrhundert', description: '2000+' },
{ key: 'living', label: 'Noch lebend', description: '' },
];
const PROFESSION_OPTIONS = [
{ key: 'philosopher', label: 'Philosoph' },
{ key: 'writer', label: 'Schriftsteller' },
{ key: 'scientist', label: 'Wissenschaftler' },
{ key: 'politician', label: 'Politiker' },
{ key: 'artist', label: 'Künstler' },
{ key: 'entrepreneur', label: 'Unternehmer' },
{ key: 'poet', label: 'Dichter' },
{ key: 'activist', label: 'Aktivist' },
{ key: 'philosopher', label: 'Philosoph' },
{ key: 'writer', label: 'Schriftsteller' },
{ key: 'scientist', label: 'Wissenschaftler' },
{ key: 'politician', label: 'Politiker' },
{ key: 'artist', label: 'Künstler' },
{ key: 'entrepreneur', label: 'Unternehmer' },
{ key: 'poet', label: 'Dichter' },
{ key: 'activist', label: 'Aktivist' },
];
const NATIONALITY_OPTIONS = [
{ key: 'german', label: 'Deutsch' },
{ key: 'american', label: 'Amerikanisch' },
{ key: 'british', label: 'Britisch' },
{ key: 'french', label: 'Französisch' },
{ key: 'italian', label: 'Italienisch' },
{ key: 'spanish', label: 'Spanisch' },
{ key: 'greek', label: 'Griechisch' },
{ key: 'roman', label: 'Römisch' },
{ key: 'german', label: 'Deutsch' },
{ key: 'american', label: 'Amerikanisch' },
{ key: 'british', label: 'Britisch' },
{ key: 'french', label: 'Französisch' },
{ key: 'italian', label: 'Italienisch' },
{ key: 'spanish', label: 'Spanisch' },
{ key: 'greek', label: 'Griechisch' },
{ key: 'roman', label: 'Römisch' },
];
const QUOTE_COUNT_OPTIONS = [
{ key: 'few', label: 'Wenige', description: '1-5' },
{ key: 'medium', label: 'Mittel', description: '6-15' },
{ key: 'many', label: 'Viele', description: '16-50' },
{ key: 'verymany', label: 'Sehr viele', description: '50+' },
{ key: 'few', label: 'Wenige', description: '1-5' },
{ key: 'medium', label: 'Mittel', description: '6-15' },
{ key: 'many', label: 'Viele', description: '16-50' },
{ key: 'verymany', label: 'Sehr viele', description: '50+' },
];
const SPECIAL_OPTIONS = [
{ key: 'verified', label: 'Verifiziert' },
{ key: 'featured', label: 'Featured' },
{ key: 'hasImage', label: 'Mit Bild' },
{ key: 'hasBio', label: 'Mit Biografie' },
{ key: 'verified', label: 'Verifiziert' },
{ key: 'featured', label: 'Featured' },
{ key: 'hasImage', label: 'Mit Bild' },
{ key: 'hasBio', label: 'Mit Biografie' },
];
export function AuthorFilterBottomSheet({
bottomSheetRef,
filters,
onFiltersChange,
onClearAll
bottomSheetRef,
filters,
onFiltersChange,
onClearAll,
}: AuthorFilterBottomSheetProps) {
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const snapPoints = useMemo(() => ['65%', '85%'], []);
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const snapPoints = useMemo(() => ['65%', '85%'], []);
const toggleFilter = (category: keyof AuthorFilters, value: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const toggleFilter = (category: keyof AuthorFilters, value: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const currentFilters = filters[category];
const newFilters = currentFilters.includes(value)
? currentFilters.filter(v => v !== value)
: [...currentFilters, value];
const currentFilters = filters[category];
const newFilters = currentFilters.includes(value)
? currentFilters.filter((v) => v !== value)
: [...currentFilters, value];
onFiltersChange({
...filters,
[category]: newFilters
});
};
onFiltersChange({
...filters,
[category]: newFilters,
});
};
const hasActiveFilters = Object.values(filters).some(arr => arr.length > 0);
const hasActiveFilters = Object.values(filters).some((arr) => arr.length > 0);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
);
const renderFilterChip = (
category: keyof AuthorFilters,
option: { key: string; label: string; description?: string }
) => {
const isActive = filters[category].includes(option.key);
const renderFilterChip = (
category: keyof AuthorFilters,
option: { key: string; label: string; description?: string }
) => {
const isActive = filters[category].includes(option.key);
return (
<Pressable
key={option.key}
onPress={() => toggleFilter(category, option.key)}
style={[
styles.chip,
{
backgroundColor: isActive
? 'rgba(124, 58, 237, 0.2)'
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
borderWidth: isActive ? 1.5 : 1,
borderColor: isActive
? '#7c3aed'
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}
]}
>
<View style={styles.chipContent}>
{isActive && (
<Icon
name="checkmark-circle"
size={16}
color="#7c3aed"
style={{ marginRight: 6 }}
/>
)}
<Text
variant="bodySmall"
weight={isActive ? 'semibold' : 'medium'}
style={{ color: isActive ? '#7c3aed' : isDarkMode ? '#fff' : '#000' }}
>
{option.label}
</Text>
{option.description && (
<Text
variant="caption"
style={{
color: isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
marginLeft: 4
}}
>
{option.description}
</Text>
)}
</View>
</Pressable>
);
};
return (
<Pressable
key={option.key}
onPress={() => toggleFilter(category, option.key)}
style={[
styles.chip,
{
backgroundColor: isActive
? 'rgba(124, 58, 237, 0.2)'
: isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.05)',
borderWidth: isActive ? 1.5 : 1,
borderColor: isActive
? '#7c3aed'
: isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.1)',
},
]}
>
<View style={styles.chipContent}>
{isActive && (
<Icon name="checkmark-circle" size={16} color="#7c3aed" style={{ marginRight: 6 }} />
)}
<Text
variant="bodySmall"
weight={isActive ? 'semibold' : 'medium'}
style={{ color: isActive ? '#7c3aed' : isDarkMode ? '#fff' : '#000' }}
>
{option.label}
</Text>
{option.description && (
<Text
variant="caption"
style={{
color: isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
marginLeft: 4,
}}
>
{option.description}
</Text>
)}
</View>
</Pressable>
);
};
const renderFilterSection = (
title: string,
category: keyof AuthorFilters,
options: { key: string; label: string; description?: string }[]
) => (
<View style={styles.section}>
<Text
variant="label"
weight="semibold"
color="secondary"
style={styles.sectionTitle}
>
{title}
</Text>
<View style={styles.chipContainer}>
{options.map(option => renderFilterChip(category, option))}
</View>
</View>
);
const renderFilterSection = (
title: string,
category: keyof AuthorFilters,
options: { key: string; label: string; description?: string }[]
) => (
<View style={styles.section}>
<Text variant="label" weight="semibold" color="secondary" style={styles.sectionTitle}>
{title}
</Text>
<View style={styles.chipContainer}>
{options.map((option) => renderFilterChip(category, option))}
</View>
</View>
);
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose={true}
enableOverDrag={false}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
}}
handleIndicatorStyle={{
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)',
width: 40,
height: 4,
}}
animateOnMount={true}
>
<View style={[styles.header, {
borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}]}>
<View style={{ flex: 1 }}>
<Text variant="title" weight="bold" color="primary">
Filter
</Text>
{hasActiveFilters && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{ marginTop: 4 }}
>
<Text variant="caption" style={{ color: '#ef4444' }}>
Alle zurücksetzen
</Text>
</Pressable>
)}
</View>
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose={true}
enableOverDrag={false}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
}}
handleIndicatorStyle={{
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)',
width: 40,
height: 4,
}}
animateOnMount={true}
>
<View
style={[
styles.header,
{
borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
},
]}
>
<View style={{ flex: 1 }}>
<Text variant="title" weight="bold" color="primary">
Filter
</Text>
{hasActiveFilters && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{ marginTop: 4 }}
>
<Text variant="caption" style={{ color: '#ef4444' }}>
Alle zurücksetzen
</Text>
</Pressable>
)}
</View>
<Pressable
onPress={() => bottomSheetRef.current?.close()}
style={[styles.closeButton, {
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
}]}
>
<Icon
name="close"
size={20}
color={isDarkMode ? '#fff' : '#000'}
/>
</Pressable>
</View>
<Pressable
onPress={() => bottomSheetRef.current?.close()}
style={[
styles.closeButton,
{
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
},
]}
>
<Icon name="close" size={20} color={isDarkMode ? '#fff' : '#000'} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{renderFilterSection('Zeitepoche', 'epochs', EPOCH_OPTIONS)}
{renderFilterSection('Beruf', 'professions', PROFESSION_OPTIONS)}
{renderFilterSection('Nationalität', 'nationalities', NATIONALITY_OPTIONS)}
{renderFilterSection('Anzahl Zitate', 'quoteCount', QUOTE_COUNT_OPTIONS)}
{renderFilterSection('Besondere', 'special', SPECIAL_OPTIONS)}
</BottomSheetScrollView>
</BottomSheet>
);
<BottomSheetScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{renderFilterSection('Zeitepoche', 'epochs', EPOCH_OPTIONS)}
{renderFilterSection('Beruf', 'professions', PROFESSION_OPTIONS)}
{renderFilterSection('Nationalität', 'nationalities', NATIONALITY_OPTIONS)}
{renderFilterSection('Anzahl Zitate', 'quoteCount', QUOTE_COUNT_OPTIONS)}
{renderFilterSection('Besondere', 'special', SPECIAL_OPTIONS)}
</BottomSheetScrollView>
</BottomSheet>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
borderBottomWidth: 1,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
content: {
padding: 20,
paddingBottom: 40,
},
section: {
marginBottom: 24,
},
sectionTitle: {
marginBottom: 12,
marginLeft: 4,
},
chipContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
chip: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
marginRight: 8,
marginBottom: 8,
},
chipContent: {
flexDirection: 'row',
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
borderBottomWidth: 1,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
content: {
padding: 20,
paddingBottom: 40,
},
section: {
marginBottom: 24,
},
sectionTitle: {
marginBottom: 12,
marginLeft: 4,
},
chipContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
chip: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
marginRight: 8,
marginBottom: 8,
},
chipContent: {
flexDirection: 'row',
alignItems: 'center',
},
});

View file

@ -7,272 +7,265 @@ import * as Haptics from 'expo-haptics';
import { useTranslation } from 'react-i18next';
export interface AuthorFilters {
epochs: string[];
professions: string[];
nationalities: string[];
quoteCount: string[];
special: string[];
epochs: string[];
professions: string[];
nationalities: string[];
quoteCount: string[];
special: string[];
}
interface AuthorFilterSheetProps {
visible: boolean;
onClose: () => void;
filters: AuthorFilters;
onFiltersChange: (filters: AuthorFilters) => void;
onClearAll: () => void;
visible: boolean;
onClose: () => void;
filters: AuthorFilters;
onFiltersChange: (filters: AuthorFilters) => void;
onClearAll: () => void;
}
const EPOCH_OPTIONS = [
{ key: 'ancient', label: 'Antike', description: 'Vor 500' },
{ key: 'medieval', label: 'Mittelalter', description: '500-1500' },
{ key: 'earlyModern', label: 'Frühe Neuzeit', description: '1500-1800' },
{ key: '19th', label: '19. Jahrhundert', description: '1800-1900' },
{ key: '20th', label: '20. Jahrhundert', description: '1900-2000' },
{ key: '21st', label: '21. Jahrhundert', description: '2000+' },
{ key: 'living', label: 'Noch lebend', description: '' },
{ key: 'ancient', label: 'Antike', description: 'Vor 500' },
{ key: 'medieval', label: 'Mittelalter', description: '500-1500' },
{ key: 'earlyModern', label: 'Frühe Neuzeit', description: '1500-1800' },
{ key: '19th', label: '19. Jahrhundert', description: '1800-1900' },
{ key: '20th', label: '20. Jahrhundert', description: '1900-2000' },
{ key: '21st', label: '21. Jahrhundert', description: '2000+' },
{ key: 'living', label: 'Noch lebend', description: '' },
];
const PROFESSION_OPTIONS = [
{ key: 'philosopher', label: 'Philosoph' },
{ key: 'writer', label: 'Schriftsteller' },
{ key: 'scientist', label: 'Wissenschaftler' },
{ key: 'politician', label: 'Politiker' },
{ key: 'artist', label: 'Künstler' },
{ key: 'entrepreneur', label: 'Unternehmer' },
{ key: 'poet', label: 'Dichter' },
{ key: 'activist', label: 'Aktivist' },
{ key: 'philosopher', label: 'Philosoph' },
{ key: 'writer', label: 'Schriftsteller' },
{ key: 'scientist', label: 'Wissenschaftler' },
{ key: 'politician', label: 'Politiker' },
{ key: 'artist', label: 'Künstler' },
{ key: 'entrepreneur', label: 'Unternehmer' },
{ key: 'poet', label: 'Dichter' },
{ key: 'activist', label: 'Aktivist' },
];
const NATIONALITY_OPTIONS = [
{ key: 'german', label: 'Deutsch' },
{ key: 'american', label: 'Amerikanisch' },
{ key: 'british', label: 'Britisch' },
{ key: 'french', label: 'Französisch' },
{ key: 'italian', label: 'Italienisch' },
{ key: 'spanish', label: 'Spanisch' },
{ key: 'greek', label: 'Griechisch' },
{ key: 'roman', label: 'Römisch' },
{ key: 'german', label: 'Deutsch' },
{ key: 'american', label: 'Amerikanisch' },
{ key: 'british', label: 'Britisch' },
{ key: 'french', label: 'Französisch' },
{ key: 'italian', label: 'Italienisch' },
{ key: 'spanish', label: 'Spanisch' },
{ key: 'greek', label: 'Griechisch' },
{ key: 'roman', label: 'Römisch' },
];
const QUOTE_COUNT_OPTIONS = [
{ key: 'few', label: 'Wenige', description: '1-5' },
{ key: 'medium', label: 'Mittel', description: '6-15' },
{ key: 'many', label: 'Viele', description: '16-50' },
{ key: 'verymany', label: 'Sehr viele', description: '50+' },
{ key: 'few', label: 'Wenige', description: '1-5' },
{ key: 'medium', label: 'Mittel', description: '6-15' },
{ key: 'many', label: 'Viele', description: '16-50' },
{ key: 'verymany', label: 'Sehr viele', description: '50+' },
];
const SPECIAL_OPTIONS = [
{ key: 'verified', label: 'Verifiziert' },
{ key: 'featured', label: 'Featured' },
{ key: 'hasImage', label: 'Mit Bild' },
{ key: 'hasBio', label: 'Mit Biografie' },
{ key: 'verified', label: 'Verifiziert' },
{ key: 'featured', label: 'Featured' },
{ key: 'hasImage', label: 'Mit Bild' },
{ key: 'hasBio', label: 'Mit Biografie' },
];
export function AuthorFilterSheet({
visible,
onClose,
filters,
onFiltersChange,
onClearAll
visible,
onClose,
filters,
onFiltersChange,
onClearAll,
}: AuthorFilterSheetProps) {
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const toggleFilter = (category: keyof AuthorFilters, value: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const toggleFilter = (category: keyof AuthorFilters, value: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const currentFilters = filters[category];
const newFilters = currentFilters.includes(value)
? currentFilters.filter(v => v !== value)
: [...currentFilters, value];
const currentFilters = filters[category];
const newFilters = currentFilters.includes(value)
? currentFilters.filter((v) => v !== value)
: [...currentFilters, value];
onFiltersChange({
...filters,
[category]: newFilters
});
};
onFiltersChange({
...filters,
[category]: newFilters,
});
};
const hasActiveFilters = Object.values(filters).some(arr => arr.length > 0);
const hasActiveFilters = Object.values(filters).some((arr) => arr.length > 0);
const renderFilterChip = (
category: keyof AuthorFilters,
option: { key: string; label: string; description?: string }
) => {
const isActive = filters[category].includes(option.key);
const renderFilterChip = (
category: keyof AuthorFilters,
option: { key: string; label: string; description?: string }
) => {
const isActive = filters[category].includes(option.key);
return (
<Pressable
key={option.key}
onPress={() => toggleFilter(category, option.key)}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: isActive
? 'rgba(124, 58, 237, 0.2)'
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
borderWidth: isActive ? 1.5 : 1,
borderColor: isActive
? '#7c3aed'
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
marginRight: 8,
marginBottom: 8,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{isActive && (
<Icon
name="checkmark-circle"
size={16}
color="#7c3aed"
style={{ marginRight: 6 }}
/>
)}
<Text
variant="bodySmall"
weight={isActive ? 'semibold' : 'medium'}
style={{ color: isActive ? '#7c3aed' : isDarkMode ? '#fff' : '#000' }}
>
{option.label}
</Text>
{option.description && (
<Text
variant="caption"
style={{
color: isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
marginLeft: 4
}}
>
{option.description}
</Text>
)}
</View>
</Pressable>
);
};
return (
<Pressable
key={option.key}
onPress={() => toggleFilter(category, option.key)}
style={{
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: isActive
? 'rgba(124, 58, 237, 0.2)'
: isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.05)',
borderWidth: isActive ? 1.5 : 1,
borderColor: isActive
? '#7c3aed'
: isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.1)',
marginRight: 8,
marginBottom: 8,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{isActive && (
<Icon name="checkmark-circle" size={16} color="#7c3aed" style={{ marginRight: 6 }} />
)}
<Text
variant="bodySmall"
weight={isActive ? 'semibold' : 'medium'}
style={{ color: isActive ? '#7c3aed' : isDarkMode ? '#fff' : '#000' }}
>
{option.label}
</Text>
{option.description && (
<Text
variant="caption"
style={{
color: isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
marginLeft: 4,
}}
>
{option.description}
</Text>
)}
</View>
</Pressable>
);
};
const renderFilterSection = (
title: string,
category: keyof AuthorFilters,
options: { key: string; label: string; description?: string }[]
) => (
<View style={{ marginBottom: 24 }}>
<Text
variant="label"
weight="semibold"
color="secondary"
style={{ marginBottom: 12, marginLeft: 4 }}
>
{title}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{options.map(option => renderFilterChip(category, option))}
</View>
</View>
);
const renderFilterSection = (
title: string,
category: keyof AuthorFilters,
options: { key: string; label: string; description?: string }[]
) => (
<View style={{ marginBottom: 24 }}>
<Text
variant="label"
weight="semibold"
color="secondary"
style={{ marginBottom: 12, marginLeft: 4 }}
>
{title}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{options.map((option) => renderFilterChip(category, option))}
</View>
</View>
);
return (
<Modal
visible={visible}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}>
<Pressable
style={{ flex: 1 }}
onPress={onClose}
/>
return (
<Modal visible={visible} transparent={true} animationType="slide" onRequestClose={onClose}>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}>
<Pressable style={{ flex: 1 }} onPress={onClose} />
<View
style={{
maxHeight: '85%',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
paddingBottom: 0,
}}
>
{/* Drag Handle */}
<View style={{
alignItems: 'center',
paddingTop: 12,
paddingBottom: 8,
}}>
<View style={{
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)',
}} />
</View>
<View
style={{
maxHeight: '85%',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
paddingBottom: 0,
}}
>
{/* Drag Handle */}
<View
style={{
alignItems: 'center',
paddingTop: 12,
paddingBottom: 8,
}}
>
<View
style={{
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)',
}}
/>
</View>
{/* Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}}>
<View style={{ flex: 1 }}>
<Text variant="title" weight="bold" color="primary">
Filter
</Text>
{hasActiveFilters && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{ marginTop: 4 }}
>
<Text variant="caption" style={{ color: '#ef4444' }}>
Alle zurücksetzen
</Text>
</Pressable>
)}
</View>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}}
>
<View style={{ flex: 1 }}>
<Text variant="title" weight="bold" color="primary">
Filter
</Text>
{hasActiveFilters && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{ marginTop: 4 }}
>
<Text variant="caption" style={{ color: '#ef4444' }}>
Alle zurücksetzen
</Text>
</Pressable>
)}
</View>
<Pressable
onPress={onClose}
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon
name="close"
size={20}
color={isDarkMode ? '#fff' : '#000'}
/>
</Pressable>
</View>
<Pressable
onPress={onClose}
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Icon name="close" size={20} color={isDarkMode ? '#fff' : '#000'} />
</Pressable>
</View>
{/* Filter Content */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
padding: 20,
paddingBottom: 20,
}}
showsVerticalScrollIndicator={false}
>
{renderFilterSection('Zeitepoche', 'epochs', EPOCH_OPTIONS)}
{renderFilterSection('Beruf', 'professions', PROFESSION_OPTIONS)}
{renderFilterSection('Nationalität', 'nationalities', NATIONALITY_OPTIONS)}
{renderFilterSection('Anzahl Zitate', 'quoteCount', QUOTE_COUNT_OPTIONS)}
{renderFilterSection('Besondere', 'special', SPECIAL_OPTIONS)}
</ScrollView>
</View>
</View>
</Modal>
);
{/* Filter Content */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
padding: 20,
paddingBottom: 20,
}}
showsVerticalScrollIndicator={false}
>
{renderFilterSection('Zeitepoche', 'epochs', EPOCH_OPTIONS)}
{renderFilterSection('Beruf', 'professions', PROFESSION_OPTIONS)}
{renderFilterSection('Nationalität', 'nationalities', NATIONALITY_OPTIONS)}
{renderFilterSection('Anzahl Zitate', 'quoteCount', QUOTE_COUNT_OPTIONS)}
{renderFilterSection('Besondere', 'special', SPECIAL_OPTIONS)}
</ScrollView>
</View>
</View>
</Modal>
);
}

View file

@ -8,69 +8,62 @@ export type ButtonVariant = 'default' | 'bordered' | 'borderless';
export type ButtonSize = 'small' | 'medium' | 'large';
interface ButtonProps {
children: React.ReactNode;
onPress: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
style?: ViewStyle;
className?: string;
hapticFeedback?: boolean;
children: React.ReactNode;
onPress: () => void;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
style?: ViewStyle;
className?: string;
hapticFeedback?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
onPress,
variant = 'default',
size = 'medium',
disabled = false,
style,
className = '',
hapticFeedback = true
children,
onPress,
variant = 'default',
size = 'medium',
disabled = false,
style,
className = '',
hapticFeedback = true,
}) => {
const isDarkMode = useIsDarkMode();
const handlePress = () => {
if (disabled) return;
if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress();
};
const isDarkMode = useIsDarkMode();
// Determine host size based on size prop
const getHostStyle = (): ViewStyle => {
const baseStyle: ViewStyle = {
...style,
};
const handlePress = () => {
if (disabled) return;
switch (size) {
case 'small':
return { ...baseStyle, minHeight: 32 };
case 'medium':
return { ...baseStyle, minHeight: 44 };
case 'large':
return { ...baseStyle, minHeight: 56 };
default:
return baseStyle;
}
};
if (hapticFeedback) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress();
};
return (
<Host
matchContents
style={getHostStyle()}
className={className}
>
<ExpoButton
onPress={handlePress}
variant={variant}
>
{children}
</ExpoButton>
</Host>
);
// Determine host size based on size prop
const getHostStyle = (): ViewStyle => {
const baseStyle: ViewStyle = {
...style,
};
switch (size) {
case 'small':
return { ...baseStyle, minHeight: 32 };
case 'medium':
return { ...baseStyle, minHeight: 44 };
case 'large':
return { ...baseStyle, minHeight: 56 };
default:
return baseStyle;
}
};
return (
<Host matchContents style={getHostStyle()} className={className}>
<ExpoButton onPress={handlePress} variant={variant}>
{children}
</ExpoButton>
</Host>
);
};
export default Button;
export default Button;

View file

@ -2,114 +2,111 @@ import React, { useState } from 'react';
import { Pressable } from 'react-native';
import { Icon } from '~/components/Icon';
import * as Haptics from 'expo-haptics';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withSequence
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withSequence,
} from 'react-native-reanimated';
import { useIsDarkMode } from '~/store/settingsStore';
import usePremiumStore from '~/store/premiumStore';
import { PremiumLimitDialog } from '~/components/PremiumLimitDialog';
interface FavoriteButtonProps {
isFavorite: boolean;
onToggle: () => void;
size?: number;
variant?: 'default' | 'daily';
className?: string;
isFavorite: boolean;
onToggle: () => void;
size?: number;
variant?: 'default' | 'daily';
className?: string;
}
export default function FavoriteButton({
isFavorite,
onToggle,
size = 24,
variant = 'default',
className = ''
export default function FavoriteButton({
isFavorite,
onToggle,
size = 24,
variant = 'default',
className = '',
}: FavoriteButtonProps) {
const isDarkMode = useIsDarkMode();
const scale = useSharedValue(1);
const heartScale = useSharedValue(1);
const [showLimitDialog, setShowLimitDialog] = useState(false);
const { canAddFavorite, addFavorite, getRemainingFavorites, MAX_DAILY_FAVORITES } = usePremiumStore();
const isDarkMode = useIsDarkMode();
const scale = useSharedValue(1);
const heartScale = useSharedValue(1);
const [showLimitDialog, setShowLimitDialog] = useState(false);
const handlePress = () => {
// If removing favorite, always allow
if (isFavorite) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
scale.value = withSpring(1.2, { duration: 150 }, () => {
scale.value = withSpring(1, { duration: 150 });
});
onToggle();
return;
}
const { canAddFavorite, addFavorite, getRemainingFavorites, MAX_DAILY_FAVORITES } =
usePremiumStore();
// Check if user can add favorite
if (!canAddFavorite()) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
setShowLimitDialog(true);
return;
}
const handlePress = () => {
// If removing favorite, always allow
if (isFavorite) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
scale.value = withSpring(1.2, { duration: 150 }, () => {
scale.value = withSpring(1, { duration: 150 });
});
onToggle();
return;
}
// Add favorite with limit tracking
if (addFavorite()) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Animation sequence for adding favorite
heartScale.value = withSequence(
withSpring(1.4, { duration: 200 }),
withSpring(0.8, { duration: 150 }),
withSpring(1, { duration: 200 })
);
onToggle();
}
};
// Check if user can add favorite
if (!canAddFavorite()) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
setShowLimitDialog(true);
return;
}
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }]
}));
// Add favorite with limit tracking
if (addFavorite()) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
const heartAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: heartScale.value }]
}));
// Animation sequence for adding favorite
heartScale.value = withSequence(
withSpring(1.4, { duration: 200 }),
withSpring(0.8, { duration: 150 }),
withSpring(1, { duration: 200 })
);
const getColor = () => {
if (isFavorite) {
return variant === 'daily' ? '#ff6b6b' : '#ef4444';
}
if (variant === 'daily') {
return 'white';
}
return 'rgba(255,255,255,0.8)';
};
onToggle();
}
};
return (
<>
<Pressable
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className={variant === 'daily' ? "bg-white/10 p-2 rounded-full" : className}
>
<Animated.View style={isFavorite ? heartAnimatedStyle : animatedStyle}>
<Icon
name={isFavorite ? 'heart' : 'heart-outline'}
size={size}
color={getColor()}
/>
</Animated.View>
</Pressable>
<PremiumLimitDialog
visible={showLimitDialog}
onClose={() => setShowLimitDialog(false)}
limitType="favorites"
remaining={getRemainingFavorites()}
max={MAX_DAILY_FAVORITES}
/>
</>
);
}
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const heartAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: heartScale.value }],
}));
const getColor = () => {
if (isFavorite) {
return variant === 'daily' ? '#ff6b6b' : '#ef4444';
}
if (variant === 'daily') {
return 'white';
}
return 'rgba(255,255,255,0.8)';
};
return (
<>
<Pressable
onPress={handlePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className={variant === 'daily' ? 'bg-white/10 p-2 rounded-full' : className}
>
<Animated.View style={isFavorite ? heartAnimatedStyle : animatedStyle}>
<Icon name={isFavorite ? 'heart' : 'heart-outline'} size={size} color={getColor()} />
</Animated.View>
</Pressable>
<PremiumLimitDialog
visible={showLimitDialog}
onClose={() => setShowLimitDialog(false)}
limitType="favorites"
remaining={getRemainingFavorites()}
max={MAX_DAILY_FAVORITES}
/>
</>
);
}

View file

@ -4,141 +4,130 @@ import { BlurView } from 'expo-blur';
import { Icon } from '~/components/Icon';
import { useIsDarkMode } from '~/store/settingsStore';
import * as Haptics from 'expo-haptics';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
FadeIn,
ZoomIn
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
FadeIn,
ZoomIn,
} from 'react-native-reanimated';
interface GlassFABProps {
onPress: () => void;
icon?: string;
size?: 'small' | 'medium' | 'large';
position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
style?: ViewStyle;
disabled?: boolean;
onPress: () => void;
icon?: string;
size?: 'small' | 'medium' | 'large';
position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
style?: ViewStyle;
disabled?: boolean;
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function GlassFAB({
onPress,
icon = 'add',
size = 'medium',
position = 'bottom-right',
style,
disabled = false
export function GlassFAB({
onPress,
icon = 'add',
size = 'medium',
position = 'bottom-right',
style,
disabled = false,
}: GlassFABProps) {
const isDarkMode = useIsDarkMode();
const scale = useSharedValue(1);
const rotation = useSharedValue(0);
const isDarkMode = useIsDarkMode();
const scale = useSharedValue(1);
const rotation = useSharedValue(0);
// Size configurations
const sizes = {
small: { container: 49, icon: 24, blur: 70 },
medium: { container: 57, icon: 30, blur: 80 },
large: { container: 65, icon: 36, blur: 90 }
};
// Size configurations
const sizes = {
small: { container: 49, icon: 24, blur: 70 },
medium: { container: 57, icon: 30, blur: 80 },
large: { container: 65, icon: 36, blur: 90 },
};
const currentSize = sizes[size];
const currentSize = sizes[size];
// Position configurations
const positions = {
'bottom-right': 'bottom-28 right-7',
'bottom-left': 'bottom-28 left-7',
'bottom-center': 'bottom-28 left-1/2 -translate-x-1/2'
};
// Position configurations
const positions = {
'bottom-right': 'bottom-28 right-7',
'bottom-left': 'bottom-28 left-7',
'bottom-center': 'bottom-28 left-1/2 -translate-x-1/2',
};
const handlePressIn = () => {
if (!disabled) {
scale.value = withSpring(0.92);
}
};
const handlePressIn = () => {
if (!disabled) {
scale.value = withSpring(0.92);
}
};
const handlePressOut = () => {
if (!disabled) {
scale.value = withSpring(1);
}
};
const handlePressOut = () => {
if (!disabled) {
scale.value = withSpring(1);
}
};
const handlePress = () => {
if (!disabled) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
rotation.value = withSpring(rotation.value + 90);
onPress();
}
};
const handlePress = () => {
if (!disabled) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
rotation.value = withSpring(rotation.value + 90);
onPress();
}
};
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ scale: scale.value },
{ rotate: `${rotation.value}deg` }
]
}));
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }, { rotate: `${rotation.value}deg` }],
}));
const opacityStyle = useAnimatedStyle(() => ({
opacity: disabled ? 0.5 : 1
}));
const opacityStyle = useAnimatedStyle(() => ({
opacity: disabled ? 0.5 : 1,
}));
return (
<Animated.View
entering={FadeIn.delay(200).duration(400).springify()}
className={`absolute ${positions[position]}`}
style={[opacityStyle, style]}
>
<AnimatedPressable
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
style={animatedStyle}
disabled={disabled}
>
<View className="relative">
{/* Outer glass container with clean border */}
<View
className="rounded-full overflow-hidden shadow-xl"
style={{
width: currentSize.container,
height: currentSize.container,
borderWidth: 1,
borderColor: isDarkMode
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(255, 255, 255, 0.8)',
}}
>
<BlurView
intensity={currentSize.blur}
tint={isDarkMode ? 'dark' : 'light'}
style={{ flex: 1 }}
/>
{/* Glass overlay - more subtle to show blur through */}
<View
className="absolute inset-0"
style={{
backgroundColor: isDarkMode
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(255, 255, 255, 0.3)',
}}
/>
</View>
return (
<Animated.View
entering={FadeIn.delay(200).duration(400).springify()}
className={`absolute ${positions[position]}`}
style={[opacityStyle, style]}
>
<AnimatedPressable
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
style={animatedStyle}
disabled={disabled}
>
<View className="relative">
{/* Outer glass container with clean border */}
<View
className="rounded-full overflow-hidden shadow-xl"
style={{
width: currentSize.container,
height: currentSize.container,
borderWidth: 1,
borderColor: isDarkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(255, 255, 255, 0.8)',
}}
>
<BlurView
intensity={currentSize.blur}
tint={isDarkMode ? 'dark' : 'light'}
style={{ flex: 1 }}
/>
{/* Glass overlay - more subtle to show blur through */}
<View
className="absolute inset-0"
style={{
backgroundColor: isDarkMode
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(255, 255, 255, 0.3)',
}}
/>
</View>
{/* Icon container - centered */}
<View
className="absolute inset-0 rounded-full items-center justify-center"
>
<Icon
name={icon}
size={currentSize.icon}
color={isDarkMode ? '#ffffff' : '#000000'}
/>
</View>
{/* Icon container - centered */}
<View className="absolute inset-0 rounded-full items-center justify-center">
<Icon name={icon} size={currentSize.icon} color={isDarkMode ? '#ffffff' : '#000000'} />
</View>
{/* Optional badge or notification dot */}
{/* Can be added here if needed */}
</View>
</AnimatedPressable>
</Animated.View>
);
}
{/* Optional badge or notification dot */}
{/* Can be added here if needed */}
</View>
</AnimatedPressable>
</Animated.View>
);
}

View file

@ -3,218 +3,220 @@ import { View, Pressable, Platform, Dimensions } from 'react-native';
import Text from '~/components/Text';
import * as Haptics from 'expo-haptics';
import { useIsDarkMode } from '~/store/settingsStore';
import Animated, {
FadeInDown,
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
Extrapolate,
runOnJS
import Animated, {
FadeInDown,
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
Extrapolate,
runOnJS,
} from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
interface Tab {
key: string;
label: string;
count?: number;
key: string;
label: string;
count?: number;
}
interface GlassTabSelectorProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tab: string) => void;
animationDelay?: number;
tabs: Tab[];
activeTab: string;
onTabChange: (tab: string) => void;
animationDelay?: number;
}
const { width: screenWidth } = Dimensions.get('window');
export function GlassTabSelector({ tabs, activeTab, onTabChange, animationDelay = 200 }: GlassTabSelectorProps) {
const isDarkMode = useIsDarkMode();
const isIOS = Platform.OS === 'ios';
// Animation values
const activeIndex = tabs.findIndex(tab => tab.key === activeTab);
const containerHorizontalPadding = 48; // px-6 = 24px on each side
const availableWidth = screenWidth - containerHorizontalPadding;
const tabWidth = availableWidth / tabs.length;
export function GlassTabSelector({
tabs,
activeTab,
onTabChange,
animationDelay = 200,
}: GlassTabSelectorProps) {
const isDarkMode = useIsDarkMode();
const isIOS = Platform.OS === 'ios';
const translateX = useSharedValue(activeIndex * tabWidth);
const indicatorWidth = useSharedValue(tabWidth);
const indicatorOpacity = useSharedValue(1);
const isFirstRender = useSharedValue(true);
// Animation values
const activeIndex = tabs.findIndex((tab) => tab.key === activeTab);
const containerHorizontalPadding = 48; // px-6 = 24px on each side
const availableWidth = screenWidth - containerHorizontalPadding;
const tabWidth = availableWidth / tabs.length;
useEffect(() => {
// Calculate position for active tab (no extra offset needed)
const newPosition = activeIndex * tabWidth;
const translateX = useSharedValue(activeIndex * tabWidth);
const indicatorWidth = useSharedValue(tabWidth);
const indicatorOpacity = useSharedValue(1);
const isFirstRender = useSharedValue(true);
// Skip animation on first render
if (isFirstRender.value) {
translateX.value = newPosition;
indicatorWidth.value = tabWidth;
isFirstRender.value = false;
return;
}
useEffect(() => {
// Calculate position for active tab (no extra offset needed)
const newPosition = activeIndex * tabWidth;
// Morph effect: briefly fade out, move, then fade in
indicatorOpacity.value = withTiming(0.8, { duration: 100 }, () => {
translateX.value = withSpring(newPosition, {
damping: 20,
stiffness: 200,
mass: 0.8
});
indicatorOpacity.value = withTiming(1, { duration: 200 });
});
// Skip animation on first render
if (isFirstRender.value) {
translateX.value = newPosition;
indicatorWidth.value = tabWidth;
isFirstRender.value = false;
return;
}
// Set indicator width to match tab width
indicatorWidth.value = withSpring(tabWidth, {
damping: 18,
stiffness: 180,
});
}, [activeIndex, tabWidth]);
// Morph effect: briefly fade out, move, then fade in
indicatorOpacity.value = withTiming(0.8, { duration: 100 }, () => {
translateX.value = withSpring(newPosition, {
damping: 20,
stiffness: 200,
mass: 0.8,
});
indicatorOpacity.value = withTiming(1, { duration: 200 });
});
const animatedIndicatorStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
width: indicatorWidth.value,
opacity: indicatorOpacity.value,
};
});
// Set indicator width to match tab width
indicatorWidth.value = withSpring(tabWidth, {
damping: 18,
stiffness: 180,
});
}, [activeIndex, tabWidth]);
if (!isIOS) {
// Fallback to regular TabSelector on Android
const TabSelector = require('./TabSelector').TabSelector;
return <TabSelector tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} animationDelay={animationDelay} />;
}
const animatedIndicatorStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
width: indicatorWidth.value,
opacity: indicatorOpacity.value,
};
});
return (
<View>
{/* Glass Effect Container Background */}
<View className="px-6 mb-6">
<View className="relative">
{/* Blur Background Layer for entire container */}
<View className="absolute inset-0 rounded-full overflow-hidden">
<BlurView
intensity={30}
tint={isDarkMode ? 'dark' : 'light'}
style={{ flex: 1 }}
/>
{/* Glass overlay with subtle gradient */}
<View
className="absolute inset-0"
style={{
backgroundColor: isDarkMode
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.5)',
borderWidth: 0.5,
borderColor: isDarkMode
? 'rgba(255, 255, 255, 0.15)'
: 'rgba(255, 255, 255, 0.8)',
borderRadius: 999,
}}
/>
</View>
if (!isIOS) {
// Fallback to regular TabSelector on Android
const TabSelector = require('./TabSelector').TabSelector;
return (
<TabSelector
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
animationDelay={animationDelay}
/>
);
}
{/* Tab buttons container - no extra padding */}
<View className="relative">
{/* Animated active indicator that slides behind tabs */}
<Animated.View
style={[
{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: 999,
overflow: 'hidden',
},
animatedIndicatorStyle
]}
>
<BlurView
intensity={60}
tint={isDarkMode ? 'light' : 'dark'}
style={{ flex: 1 }}
/>
<View
className="absolute inset-0"
style={{
backgroundColor: isDarkMode
? 'rgba(255, 255, 255, 0.95)'
: 'rgba(0, 0, 0, 0.88)',
borderWidth: 0.5,
borderColor: isDarkMode
? 'rgba(255, 255, 255, 0.3)'
: 'rgba(0, 0, 0, 0.2)',
borderRadius: 999,
}}
/>
</Animated.View>
return (
<View>
{/* Glass Effect Container Background */}
<View className="px-6 mb-6">
<View className="relative">
{/* Blur Background Layer for entire container */}
<View className="absolute inset-0 rounded-full overflow-hidden">
<BlurView intensity={30} tint={isDarkMode ? 'dark' : 'light'} style={{ flex: 1 }} />
{/* Glass overlay with subtle gradient */}
<View
className="absolute inset-0"
style={{
backgroundColor: isDarkMode
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(255, 255, 255, 0.5)',
borderWidth: 0.5,
borderColor: isDarkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(255, 255, 255, 0.8)',
borderRadius: 999,
}}
/>
</View>
{/* Tab buttons - now in a flex row */}
<View className="flex-row">
{tabs.map((tab, index) => {
const isActive = activeTab === tab.key;
// Individual tab animations
const tabScale = useSharedValue(1);
const textOpacity = useSharedValue(isActive ? 1 : 0.7);
useEffect(() => {
textOpacity.value = withTiming(isActive ? 1 : 0.7, { duration: 300 });
}, [isActive]);
const animatedTabStyle = useAnimatedStyle(() => ({
transform: [{ scale: tabScale.value }],
}));
const animatedTextStyle = useAnimatedStyle(() => ({
opacity: textOpacity.value,
}));
return (
<Pressable
key={tab.key}
onPressIn={() => {
tabScale.value = withSpring(0.95);
}}
onPressOut={() => {
tabScale.value = withSpring(1);
}}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onTabChange(tab.key);
}}
style={{ width: tabWidth }}
>
<Animated.View style={[{ position: 'relative' }, animatedTabStyle]}>
{/* Tab content - consistent padding for all tabs */}
<View className="py-2.5 px-4 rounded-full">
<Animated.Text
style={[
{
textAlign: 'center',
fontWeight: '500',
fontSize: 15,
color: isActive
? (isDarkMode ? '#000000' : '#FFFFFF')
: (isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)')
},
animatedTextStyle
]}
>
{tab.label}
{tab.count !== undefined && ` (${tab.count})`}
</Animated.Text>
</View>
</Animated.View>
</Pressable>
);
})}
</View>
</View>
</View>
</View>
</View>
);
}
{/* Tab buttons container - no extra padding */}
<View className="relative">
{/* Animated active indicator that slides behind tabs */}
<Animated.View
style={[
{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: 999,
overflow: 'hidden',
},
animatedIndicatorStyle,
]}
>
<BlurView intensity={60} tint={isDarkMode ? 'light' : 'dark'} style={{ flex: 1 }} />
<View
className="absolute inset-0"
style={{
backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.95)' : 'rgba(0, 0, 0, 0.88)',
borderWidth: 0.5,
borderColor: isDarkMode ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.2)',
borderRadius: 999,
}}
/>
</Animated.View>
{/* Tab buttons - now in a flex row */}
<View className="flex-row">
{tabs.map((tab, index) => {
const isActive = activeTab === tab.key;
// Individual tab animations
const tabScale = useSharedValue(1);
const textOpacity = useSharedValue(isActive ? 1 : 0.7);
useEffect(() => {
textOpacity.value = withTiming(isActive ? 1 : 0.7, { duration: 300 });
}, [isActive]);
const animatedTabStyle = useAnimatedStyle(() => ({
transform: [{ scale: tabScale.value }],
}));
const animatedTextStyle = useAnimatedStyle(() => ({
opacity: textOpacity.value,
}));
return (
<Pressable
key={tab.key}
onPressIn={() => {
tabScale.value = withSpring(0.95);
}}
onPressOut={() => {
tabScale.value = withSpring(1);
}}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onTabChange(tab.key);
}}
style={{ width: tabWidth }}
>
<Animated.View style={[{ position: 'relative' }, animatedTabStyle]}>
{/* Tab content - consistent padding for all tabs */}
<View className="py-2.5 px-4 rounded-full">
<Animated.Text
style={[
{
textAlign: 'center',
fontWeight: '500',
fontSize: 15,
color: isActive
? isDarkMode
? '#000000'
: '#FFFFFF'
: isDarkMode
? 'rgba(255,255,255,0.7)'
: 'rgba(0,0,0,0.6)',
},
animatedTextStyle,
]}
>
{tab.label}
{tab.count !== undefined && ` (${tab.count})`}
</Animated.Text>
</View>
</Animated.View>
</Pressable>
);
})}
</View>
</View>
</View>
</View>
</View>
);
}

View file

@ -6,93 +6,81 @@ import Text from '~/components/Text';
import { useIsDarkMode } from '~/store/settingsStore';
interface HeaderProps {
title: string;
subtitle?: string;
showBackButton?: boolean;
showSettings?: boolean;
showScrollToggle?: boolean;
showSortToggle?: boolean;
sortBy?: 'name' | 'quotes';
onSortToggle?: () => void;
rightActions?: React.ReactNode;
onBackPress?: () => void;
title: string;
subtitle?: string;
showBackButton?: boolean;
showSettings?: boolean;
showScrollToggle?: boolean;
showSortToggle?: boolean;
sortBy?: 'name' | 'quotes';
onSortToggle?: () => void;
rightActions?: React.ReactNode;
onBackPress?: () => void;
}
export const Header: React.FC<HeaderProps> = ({
title,
subtitle,
showBackButton = false,
showSettings = false,
showScrollToggle = false,
showSortToggle = false,
sortBy,
onSortToggle,
rightActions,
onBackPress
title,
subtitle,
showBackButton = false,
showSettings = false,
showScrollToggle = false,
showSortToggle = false,
sortBy,
onSortToggle,
rightActions,
onBackPress,
}) => {
const router = useRouter();
const isDarkMode = useIsDarkMode();
const router = useRouter();
const isDarkMode = useIsDarkMode();
const handleBack = () => {
if (onBackPress) {
onBackPress();
} else {
router.back();
}
};
const handleBack = () => {
if (onBackPress) {
onBackPress();
} else {
router.back();
}
};
const handleSettings = () => {
router.push('/settings');
};
const handleSettings = () => {
router.push('/settings');
};
return (
<View className="px-6 py-4">
<View className="flex-row justify-between items-center">
<View className="flex-1 flex-row items-center">
{showBackButton && <IconButton icon="arrow-back" onPress={handleBack} className="mr-3" />}
<View className="flex-1">
<Text variant="h2" color="primary" weight="bold">
{title}
</Text>
{subtitle && (
<Text variant="bodySmall" color="secondary" className="mt-1">
{subtitle}
</Text>
)}
</View>
</View>
return (
<View className="px-6 py-4">
<View className="flex-row justify-between items-center">
<View className="flex-1 flex-row items-center">
{showBackButton && (
<IconButton
icon="arrow-back"
onPress={handleBack}
className="mr-3"
/>
)}
<View className="flex-1">
<Text variant="h2" color="primary" weight="bold">
{title}
</Text>
{subtitle && (
<Text variant="bodySmall" color="secondary" className="mt-1">
{subtitle}
</Text>
)}
</View>
</View>
{/* Right Actions */}
{(rightActions || showSortToggle || showScrollToggle || showSettings) && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{rightActions}
{showSortToggle && (
<IconButton
icon={sortBy === 'name' ? 'text-outline' : 'stats-chart-outline'}
onPress={() => onSortToggle?.()}
size={20}
/>
)}
{showSettings && (
<IconButton
icon="settings-outline"
onPress={handleSettings}
size={22}
/>
)}
</View>
)}
</View>
</View>
);
};
{/* Right Actions */}
{(rightActions || showSortToggle || showScrollToggle || showSettings) && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{rightActions}
{showSortToggle && (
<IconButton
icon={sortBy === 'name' ? 'text-outline' : 'stats-chart-outline'}
onPress={() => onSortToggle?.()}
size={20}
/>
)}
{showSettings && (
<IconButton icon="settings-outline" onPress={handleSettings} size={22} />
)}
</View>
)}
</View>
</View>
);
};

View file

@ -5,45 +5,41 @@ import * as Haptics from 'expo-haptics';
import { useIsDarkMode } from '~/store/settingsStore';
interface IconButtonProps {
icon: string;
size?: number;
onPress: () => void;
className?: string;
isActive?: boolean;
icon: string;
size?: number;
onPress: () => void;
className?: string;
isActive?: boolean;
}
export const IconButton: React.FC<IconButtonProps> = ({
icon,
size = 22,
onPress,
className = '',
isActive = false
icon,
size = 22,
onPress,
className = '',
isActive = false,
}) => {
const isDarkMode = useIsDarkMode();
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
};
return (
<Pressable
onPress={handlePress}
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
justifyContent: 'center',
alignItems: 'center'
}}
className={className}
>
<Icon
name={icon}
size={size}
color={isDarkMode ? 'white' : 'black'}
/>
</Pressable>
);
};
const isDarkMode = useIsDarkMode();
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
};
return (
<Pressable
onPress={handlePress}
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
className={className}
>
<Icon name={icon} size={size} color={isDarkMode ? 'white' : 'black'} />
</Pressable>
);
};

View file

@ -5,24 +5,23 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
interface LoadingScreenProps {
message?: string;
fullScreen?: boolean;
message?: string;
fullScreen?: boolean;
}
export const LoadingScreen: React.FC<LoadingScreenProps> = ({
message,
fullScreen = true,
}) => {
const { t } = useTranslation();
const Container = fullScreen ? SafeAreaView : View;
const displayMessage = message || t('common.loading');
return (
<Container className="flex-1 bg-black">
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#ffffff" />
<Text variant="body" color="tertiary" className="mt-4">{displayMessage}</Text>
</View>
</Container>
);
};
export const LoadingScreen: React.FC<LoadingScreenProps> = ({ message, fullScreen = true }) => {
const { t } = useTranslation();
const Container = fullScreen ? SafeAreaView : View;
const displayMessage = message || t('common.loading');
return (
<Container className="flex-1 bg-black">
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#ffffff" />
<Text variant="body" color="tertiary" className="mt-4">
{displayMessage}
</Text>
</View>
</Container>
);
};

View file

@ -3,166 +3,171 @@ import { View, Pressable, Dimensions } from 'react-native';
import Text from '~/components/Text';
import * as Haptics from 'expo-haptics';
import { useIsDarkMode } from '~/store/settingsStore';
import Animated, {
FadeInDown,
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolateColor
import Animated, {
FadeInDown,
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
interface Tab {
key: string;
label: string;
count?: number;
key: string;
label: string;
count?: number;
}
interface TabSelectorProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tab: string) => void;
animationDelay?: number;
tabs: Tab[];
activeTab: string;
onTabChange: (tab: string) => void;
animationDelay?: number;
}
const { width: screenWidth } = Dimensions.get('window');
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export function TabSelector({ tabs, activeTab, onTabChange, animationDelay = 200 }: TabSelectorProps) {
const isDarkMode = useIsDarkMode();
// Animation values for sliding indicator
const activeIndex = tabs.findIndex(tab => tab.key === activeTab);
const translateX = useSharedValue(0);
const indicatorWidth = useSharedValue(0);
// Calculate tab width
const containerPadding = 24 * 2; // px-6 on both sides
const spacing = 8; // ml-2 between tabs
const availableWidth = screenWidth - containerPadding - (spacing * (tabs.length - 1));
const tabWidth = availableWidth / tabs.length;
export function TabSelector({
tabs,
activeTab,
onTabChange,
animationDelay = 200,
}: TabSelectorProps) {
const isDarkMode = useIsDarkMode();
useEffect(() => {
// Calculate position including spacing
const spacingOffset = activeIndex * spacing;
const newPosition = (activeIndex * tabWidth) + spacingOffset;
// Animate to new position with spring
translateX.value = withSpring(newPosition, {
damping: 20,
stiffness: 200,
mass: 0.8
});
// Set width
indicatorWidth.value = withSpring(tabWidth, {
damping: 18,
stiffness: 180,
});
}, [activeIndex, tabWidth]);
// Animation values for sliding indicator
const activeIndex = tabs.findIndex((tab) => tab.key === activeTab);
const translateX = useSharedValue(0);
const indicatorWidth = useSharedValue(0);
const animatedIndicatorStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
width: indicatorWidth.value,
};
});
// Calculate tab width
const containerPadding = 24 * 2; // px-6 on both sides
const spacing = 8; // ml-2 between tabs
const availableWidth = screenWidth - containerPadding - spacing * (tabs.length - 1);
const tabWidth = availableWidth / tabs.length;
return (
<Animated.View entering={FadeInDown.delay(animationDelay).duration(600)}>
<View className="px-6 mb-6">
<View className="relative">
{/* Animated background indicator */}
<Animated.View
style={[
{
position: 'absolute',
height: '100%',
borderRadius: 999,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)',
zIndex: 0,
},
animatedIndicatorStyle
]}
/>
{/* Tab buttons */}
<View className="flex-row">
{tabs.map((tab, index) => {
const isActive = activeTab === tab.key;
// Individual tab animations
const scale = useSharedValue(1);
const backgroundOpacity = useSharedValue(isActive ? 0 : 1);
const textOpacity = useSharedValue(1);
useEffect(() => {
backgroundOpacity.value = withTiming(isActive ? 0 : 1, { duration: 300 });
textOpacity.value = withTiming(isActive ? 1 : 0.8, { duration: 300 });
}, [isActive]);
const animatedTabStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const animatedBackgroundStyle = useAnimatedStyle(() => ({
opacity: backgroundOpacity.value,
}));
const animatedTextContainerStyle = useAnimatedStyle(() => ({
opacity: textOpacity.value,
}));
return (
<Animated.View
key={tab.key}
className="flex-1"
style={{
marginLeft: index > 0 ? 8 : 0,
zIndex: 1,
}}
>
<AnimatedPressable
onPressIn={() => {
scale.value = withSpring(0.95);
}}
onPressOut={() => {
scale.value = withSpring(1);
}}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onTabChange(tab.key);
}}
className="py-3 rounded-full relative"
style={animatedTabStyle}
>
{/* Tab background (hidden when active) */}
<Animated.View
className={`absolute inset-0 rounded-full ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
style={animatedBackgroundStyle}
/>
{/* Tab content */}
<Animated.View style={animatedTextContainerStyle}>
<Text
variant="body"
color={isActive ? 'primary' : 'secondary'}
weight="medium"
className="text-center"
>
{tab.label}
{tab.count !== undefined && ` (${tab.count})`}
</Text>
</Animated.View>
</AnimatedPressable>
</Animated.View>
);
})}
</View>
</View>
</View>
</Animated.View>
);
}
useEffect(() => {
// Calculate position including spacing
const spacingOffset = activeIndex * spacing;
const newPosition = activeIndex * tabWidth + spacingOffset;
// Animate to new position with spring
translateX.value = withSpring(newPosition, {
damping: 20,
stiffness: 200,
mass: 0.8,
});
// Set width
indicatorWidth.value = withSpring(tabWidth, {
damping: 18,
stiffness: 180,
});
}, [activeIndex, tabWidth]);
const animatedIndicatorStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
width: indicatorWidth.value,
};
});
return (
<Animated.View entering={FadeInDown.delay(animationDelay).duration(600)}>
<View className="px-6 mb-6">
<View className="relative">
{/* Animated background indicator */}
<Animated.View
style={[
{
position: 'absolute',
height: '100%',
borderRadius: 999,
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)',
zIndex: 0,
},
animatedIndicatorStyle,
]}
/>
{/* Tab buttons */}
<View className="flex-row">
{tabs.map((tab, index) => {
const isActive = activeTab === tab.key;
// Individual tab animations
const scale = useSharedValue(1);
const backgroundOpacity = useSharedValue(isActive ? 0 : 1);
const textOpacity = useSharedValue(1);
useEffect(() => {
backgroundOpacity.value = withTiming(isActive ? 0 : 1, { duration: 300 });
textOpacity.value = withTiming(isActive ? 1 : 0.8, { duration: 300 });
}, [isActive]);
const animatedTabStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const animatedBackgroundStyle = useAnimatedStyle(() => ({
opacity: backgroundOpacity.value,
}));
const animatedTextContainerStyle = useAnimatedStyle(() => ({
opacity: textOpacity.value,
}));
return (
<Animated.View
key={tab.key}
className="flex-1"
style={{
marginLeft: index > 0 ? 8 : 0,
zIndex: 1,
}}
>
<AnimatedPressable
onPressIn={() => {
scale.value = withSpring(0.95);
}}
onPressOut={() => {
scale.value = withSpring(1);
}}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onTabChange(tab.key);
}}
className="py-3 rounded-full relative"
style={animatedTabStyle}
>
{/* Tab background (hidden when active) */}
<Animated.View
className={`absolute inset-0 rounded-full ${
isDarkMode ? 'bg-white/10' : 'bg-black/10'
}`}
style={animatedBackgroundStyle}
/>
{/* Tab content */}
<Animated.View style={animatedTextContainerStyle}>
<Text
variant="body"
color={isActive ? 'primary' : 'secondary'}
weight="medium"
className="text-center"
>
{tab.label}
{tab.count !== undefined && ` (${tab.count})`}
</Text>
</Animated.View>
</AnimatedPressable>
</Animated.View>
);
})}
</View>
</View>
</View>
</Animated.View>
);
}

View file

@ -1,24 +1,17 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
ScrollView,
Dimensions,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { View, Text, ScrollView, Dimensions, TouchableOpacity, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
interpolate,
Extrapolate,
withTiming,
FadeIn,
useAnimatedStyle,
useSharedValue,
withSpring,
interpolate,
Extrapolate,
withTiming,
FadeIn,
} from 'react-native-reanimated';
import * as Haptics from 'expo-haptics';
import { useRouter } from 'expo-router';
@ -28,452 +21,405 @@ import { useIsDarkMode } from '~/store/settingsStore';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
interface OnboardingPage {
icon: string;
iconColor: string;
title: string;
subtitle: string;
description: string;
gradientColors: string[];
icon: string;
iconColor: string;
title: string;
subtitle: string;
description: string;
gradientColors: string[];
}
const onboardingPages: OnboardingPage[] = [
{
icon: 'sparkles',
iconColor: '#FFD700',
title: 'Willkommen bei Zitare',
subtitle: 'Entdecke tägliche Inspiration',
description: 'Über 23.000 handverlesene Zitate von den größten Denkern der Geschichte.',
gradientColors: ['#667eea', '#764ba2'],
},
{
icon: 'heart',
iconColor: '#FF6B6B',
title: 'Sammle deine Favoriten',
subtitle: 'Behalte, was dich bewegt',
description: 'Speichere Zitate, die dich inspirieren, und greife jederzeit darauf zu.',
gradientColors: ['#f093fb', '#f5576c'],
},
{
icon: 'albums',
iconColor: '#4ECDC4',
title: 'Erstelle Sammlungen',
subtitle: 'Organisiere deine Weisheiten',
description: 'Gruppiere Zitate nach Themen, Stimmungen oder eigenen Kategorien.',
gradientColors: ['#4facfe', '#00f2fe'],
},
{
icon: 'people',
iconColor: '#9B59B6',
title: 'Entdecke Autoren',
subtitle: 'Lerne die Denker kennen',
description: 'Tauche ein in die Biografien und Gedankenwelten großer Persönlichkeiten.',
gradientColors: ['#667eea', '#764ba2'],
},
{
icon: 'phone-portrait-outline',
iconColor: '#3498DB',
title: 'Widget & Personalisierung',
subtitle: 'Dein tägliches Zitat',
description: 'Erhalte inspirierende Zitate direkt auf deinem Home-Bildschirm.',
gradientColors: ['#6dd5ed', '#2193b0'],
},
{
icon: 'sparkles',
iconColor: '#FFD700',
title: 'Willkommen bei Zitare',
subtitle: 'Entdecke tägliche Inspiration',
description: 'Über 23.000 handverlesene Zitate von den größten Denkern der Geschichte.',
gradientColors: ['#667eea', '#764ba2'],
},
{
icon: 'heart',
iconColor: '#FF6B6B',
title: 'Sammle deine Favoriten',
subtitle: 'Behalte, was dich bewegt',
description: 'Speichere Zitate, die dich inspirieren, und greife jederzeit darauf zu.',
gradientColors: ['#f093fb', '#f5576c'],
},
{
icon: 'albums',
iconColor: '#4ECDC4',
title: 'Erstelle Sammlungen',
subtitle: 'Organisiere deine Weisheiten',
description: 'Gruppiere Zitate nach Themen, Stimmungen oder eigenen Kategorien.',
gradientColors: ['#4facfe', '#00f2fe'],
},
{
icon: 'people',
iconColor: '#9B59B6',
title: 'Entdecke Autoren',
subtitle: 'Lerne die Denker kennen',
description: 'Tauche ein in die Biografien und Gedankenwelten großer Persönlichkeiten.',
gradientColors: ['#667eea', '#764ba2'],
},
{
icon: 'phone-portrait-outline',
iconColor: '#3498DB',
title: 'Widget & Personalisierung',
subtitle: 'Dein tägliches Zitat',
description: 'Erhalte inspirierende Zitate direkt auf deinem Home-Bildschirm.',
gradientColors: ['#6dd5ed', '#2193b0'],
},
];
export default function AppleStyleOnboarding() {
const [currentPage, setCurrentPage] = useState(0);
const scrollViewRef = useRef<ScrollView>(null);
const scrollX = useSharedValue(0);
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { completeOnboarding } = useOnboardingStore();
const [currentPage, setCurrentPage] = useState(0);
const scrollViewRef = useRef<ScrollView>(null);
const scrollX = useSharedValue(0);
const router = useRouter();
const isDarkMode = useIsDarkMode();
const { completeOnboarding } = useOnboardingStore();
const handleScroll = (event: any) => {
const offsetX = event.nativeEvent.contentOffset.x;
scrollX.value = offsetX;
const pageIndex = Math.round(offsetX / SCREEN_WIDTH);
if (pageIndex !== currentPage) {
setCurrentPage(pageIndex);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
const handleScroll = (event: any) => {
const offsetX = event.nativeEvent.contentOffset.x;
scrollX.value = offsetX;
const pageIndex = Math.round(offsetX / SCREEN_WIDTH);
if (pageIndex !== currentPage) {
setCurrentPage(pageIndex);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
};
const handleContinue = () => {
if (currentPage < onboardingPages.length - 1) {
const nextPage = currentPage + 1;
scrollViewRef.current?.scrollTo({
x: nextPage * SCREEN_WIDTH,
animated: true,
});
setCurrentPage(nextPage);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
handleComplete();
}
};
const handleContinue = () => {
if (currentPage < onboardingPages.length - 1) {
const nextPage = currentPage + 1;
scrollViewRef.current?.scrollTo({
x: nextPage * SCREEN_WIDTH,
animated: true,
});
setCurrentPage(nextPage);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
handleComplete();
}
};
const handleComplete = async () => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
completeOnboarding();
router.replace('/(tabs)/');
};
const handleComplete = async () => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
completeOnboarding();
router.replace('/(tabs)/');
};
const handleSkip = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
handleComplete();
};
const handleSkip = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
handleComplete();
};
return (
<View style={styles.container}>
{/* Background Gradient */}
<LinearGradient
colors={isDarkMode ? ['#000000', '#1a1a1a'] : ['#ffffff', '#f0f0f0']}
style={StyleSheet.absoluteFillObject}
/>
return (
<View style={styles.container}>
{/* Background Gradient */}
<LinearGradient
colors={isDarkMode ? ['#000000', '#1a1a1a'] : ['#ffffff', '#f0f0f0']}
style={StyleSheet.absoluteFillObject}
/>
<SafeAreaView style={styles.safeArea}>
{/* Skip Button */}
<Animated.View
entering={FadeIn.delay(500)}
style={styles.skipContainer}
>
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
<Text style={[
styles.skipText,
{ color: isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' }
]}>
Überspringen
</Text>
</TouchableOpacity>
</Animated.View>
<SafeAreaView style={styles.safeArea}>
{/* Skip Button */}
<Animated.View entering={FadeIn.delay(500)} style={styles.skipContainer}>
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
<Text
style={[
styles.skipText,
{ color: isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' },
]}
>
Überspringen
</Text>
</TouchableOpacity>
</Animated.View>
{/* Pages */}
<ScrollView
ref={scrollViewRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
bounces={false}
>
{onboardingPages.map((page, index) => (
<OnboardingPageComponent
key={index}
page={page}
index={index}
scrollX={scrollX}
isDarkMode={isDarkMode}
/>
))}
</ScrollView>
{/* Pages */}
<ScrollView
ref={scrollViewRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
bounces={false}
>
{onboardingPages.map((page, index) => (
<OnboardingPageComponent
key={index}
page={page}
index={index}
scrollX={scrollX}
isDarkMode={isDarkMode}
/>
))}
</ScrollView>
{/* Bottom Controls */}
<View style={styles.bottomContainer}>
{/* Page Indicators */}
<View style={styles.indicatorContainer}>
{onboardingPages.map((_, index) => (
<PageIndicator
key={index}
index={index}
currentPage={currentPage}
scrollX={scrollX}
isDarkMode={isDarkMode}
/>
))}
</View>
{/* Bottom Controls */}
<View style={styles.bottomContainer}>
{/* Page Indicators */}
<View style={styles.indicatorContainer}>
{onboardingPages.map((_, index) => (
<PageIndicator
key={index}
index={index}
currentPage={currentPage}
scrollX={scrollX}
isDarkMode={isDarkMode}
/>
))}
</View>
{/* Continue Button */}
<TouchableOpacity
onPress={handleContinue}
style={styles.continueButton}
activeOpacity={0.8}
>
<BlurView
intensity={80}
tint={isDarkMode ? 'dark' : 'light'}
style={styles.continueButtonBlur}
>
<Text style={[
styles.continueButtonText,
{ color: isDarkMode ? '#ffffff' : '#000000' }
]}>
{currentPage === onboardingPages.length - 1 ? 'Los geht\'s' : 'Weiter'}
</Text>
</BlurView>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
{/* Continue Button */}
<TouchableOpacity
onPress={handleContinue}
style={styles.continueButton}
activeOpacity={0.8}
>
<BlurView
intensity={80}
tint={isDarkMode ? 'dark' : 'light'}
style={styles.continueButtonBlur}
>
<Text
style={[styles.continueButtonText, { color: isDarkMode ? '#ffffff' : '#000000' }]}
>
{currentPage === onboardingPages.length - 1 ? "Los geht's" : 'Weiter'}
</Text>
</BlurView>
</TouchableOpacity>
</View>
</SafeAreaView>
</View>
);
}
// Individual Page Component
function OnboardingPageComponent({
page,
index,
scrollX,
isDarkMode,
page,
index,
scrollX,
isDarkMode,
}: {
page: OnboardingPage;
index: number;
scrollX: any;
isDarkMode: boolean;
page: OnboardingPage;
index: number;
scrollX: any;
isDarkMode: boolean;
}) {
const inputRange = [
(index - 1) * SCREEN_WIDTH,
index * SCREEN_WIDTH,
(index + 1) * SCREEN_WIDTH,
];
const inputRange = [(index - 1) * SCREEN_WIDTH, index * SCREEN_WIDTH, (index + 1) * SCREEN_WIDTH];
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(
scrollX.value,
inputRange,
[0.8, 1, 0.8],
Extrapolate.CLAMP
);
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(scrollX.value, inputRange, [0.8, 1, 0.8], Extrapolate.CLAMP);
const opacity = interpolate(
scrollX.value,
inputRange,
[0.5, 1, 0.5],
Extrapolate.CLAMP
);
const opacity = interpolate(scrollX.value, inputRange, [0.5, 1, 0.5], Extrapolate.CLAMP);
const translateY = interpolate(
scrollX.value,
inputRange,
[50, 0, 50],
Extrapolate.CLAMP
);
const translateY = interpolate(scrollX.value, inputRange, [50, 0, 50], Extrapolate.CLAMP);
return {
transform: [{ scale }, { translateY }],
opacity,
};
});
return {
transform: [{ scale }, { translateY }],
opacity,
};
});
const iconAnimatedStyle = useAnimatedStyle(() => {
const rotate = interpolate(
scrollX.value,
inputRange,
[-15, 0, 15],
Extrapolate.CLAMP
);
const iconAnimatedStyle = useAnimatedStyle(() => {
const rotate = interpolate(scrollX.value, inputRange, [-15, 0, 15], Extrapolate.CLAMP);
const scale = interpolate(
scrollX.value,
inputRange,
[0.8, 1, 0.8],
Extrapolate.CLAMP
);
const scale = interpolate(scrollX.value, inputRange, [0.8, 1, 0.8], Extrapolate.CLAMP);
return {
transform: [
{ rotate: `${rotate}deg` },
{ scale },
],
};
});
return {
transform: [{ rotate: `${rotate}deg` }, { scale }],
};
});
return (
<View style={styles.pageContainer}>
<Animated.View style={[styles.contentContainer, animatedStyle]}>
{/* Icon with Gradient Background */}
<View style={styles.iconWrapper}>
<LinearGradient
colors={page.gradientColors}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Animated.View style={iconAnimatedStyle}>
<Ionicons
name={page.icon as any}
size={60}
color="#ffffff"
/>
</Animated.View>
</LinearGradient>
</View>
return (
<View style={styles.pageContainer}>
<Animated.View style={[styles.contentContainer, animatedStyle]}>
{/* Icon with Gradient Background */}
<View style={styles.iconWrapper}>
<LinearGradient
colors={page.gradientColors}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Animated.View style={iconAnimatedStyle}>
<Ionicons name={page.icon as any} size={60} color="#ffffff" />
</Animated.View>
</LinearGradient>
</View>
{/* Title */}
<Text style={[
styles.title,
{ color: isDarkMode ? '#ffffff' : '#000000' }
]}>
{page.title}
</Text>
{/* Title */}
<Text style={[styles.title, { color: isDarkMode ? '#ffffff' : '#000000' }]}>
{page.title}
</Text>
{/* Subtitle */}
<Text style={[
styles.subtitle,
{ color: isDarkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)' }
]}>
{page.subtitle}
</Text>
{/* Subtitle */}
<Text
style={[
styles.subtitle,
{ color: isDarkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)' },
]}
>
{page.subtitle}
</Text>
{/* Description */}
<Text style={[
styles.description,
{ color: isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' }
]}>
{page.description}
</Text>
</Animated.View>
</View>
);
{/* Description */}
<Text
style={[
styles.description,
{ color: isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)' },
]}
>
{page.description}
</Text>
</Animated.View>
</View>
);
}
// Page Indicator Component
function PageIndicator({
index,
currentPage,
scrollX,
isDarkMode,
index,
currentPage,
scrollX,
isDarkMode,
}: {
index: number;
currentPage: number;
scrollX: any;
isDarkMode: boolean;
index: number;
currentPage: number;
scrollX: any;
isDarkMode: boolean;
}) {
const animatedStyle = useAnimatedStyle(() => {
const inputRange = [
(index - 1) * SCREEN_WIDTH,
index * SCREEN_WIDTH,
(index + 1) * SCREEN_WIDTH,
];
const animatedStyle = useAnimatedStyle(() => {
const inputRange = [
(index - 1) * SCREEN_WIDTH,
index * SCREEN_WIDTH,
(index + 1) * SCREEN_WIDTH,
];
const width = interpolate(
scrollX.value,
inputRange,
[8, 24, 8],
Extrapolate.CLAMP
);
const width = interpolate(scrollX.value, inputRange, [8, 24, 8], Extrapolate.CLAMP);
const opacity = interpolate(
scrollX.value,
inputRange,
[0.3, 1, 0.3],
Extrapolate.CLAMP
);
const opacity = interpolate(scrollX.value, inputRange, [0.3, 1, 0.3], Extrapolate.CLAMP);
return {
width,
opacity,
};
});
return {
width,
opacity,
};
});
return (
<Animated.View
style={[
styles.indicator,
animatedStyle,
{
backgroundColor: isDarkMode ? '#ffffff' : '#000000',
},
]}
/>
);
return (
<Animated.View
style={[
styles.indicator,
animatedStyle,
{
backgroundColor: isDarkMode ? '#ffffff' : '#000000',
},
]}
/>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
safeArea: {
flex: 1,
},
skipContainer: {
position: 'absolute',
top: 60,
right: 20,
zIndex: 10,
},
skipButton: {
padding: 10,
},
skipText: {
fontSize: 17,
fontWeight: '400',
},
pageContainer: {
width: SCREEN_WIDTH,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
},
contentContainer: {
alignItems: 'center',
justifyContent: 'center',
},
iconWrapper: {
marginBottom: 40,
},
iconGradient: {
width: 120,
height: 120,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 10,
},
title: {
fontSize: 32,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 22,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
description: {
fontSize: 17,
fontWeight: '400',
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: 20,
},
bottomContainer: {
position: 'absolute',
bottom: 50,
left: 0,
right: 0,
alignItems: 'center',
},
indicatorContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 30,
gap: 8,
},
indicator: {
height: 8,
borderRadius: 4,
backgroundColor: '#000000',
},
continueButton: {
borderRadius: 30,
overflow: 'hidden',
},
continueButtonBlur: {
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 30,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.2)',
},
continueButtonText: {
fontSize: 17,
fontWeight: '600',
letterSpacing: -0.2,
},
});
container: {
flex: 1,
},
safeArea: {
flex: 1,
},
skipContainer: {
position: 'absolute',
top: 60,
right: 20,
zIndex: 10,
},
skipButton: {
padding: 10,
},
skipText: {
fontSize: 17,
fontWeight: '400',
},
pageContainer: {
width: SCREEN_WIDTH,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
},
contentContainer: {
alignItems: 'center',
justifyContent: 'center',
},
iconWrapper: {
marginBottom: 40,
},
iconGradient: {
width: 120,
height: 120,
borderRadius: 30,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 10,
},
title: {
fontSize: 32,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
letterSpacing: -0.5,
},
subtitle: {
fontSize: 22,
fontWeight: '600',
textAlign: 'center',
marginBottom: 20,
},
description: {
fontSize: 17,
fontWeight: '400',
textAlign: 'center',
lineHeight: 24,
paddingHorizontal: 20,
},
bottomContainer: {
position: 'absolute',
bottom: 50,
left: 0,
right: 0,
alignItems: 'center',
},
indicatorContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 30,
gap: 8,
},
indicator: {
height: 8,
borderRadius: 4,
backgroundColor: '#000000',
},
continueButton: {
borderRadius: 30,
overflow: 'hidden',
},
continueButtonBlur: {
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 30,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.2)',
},
continueButtonText: {
fontSize: 17,
fontWeight: '600',
letterSpacing: -0.2,
},
});

View file

@ -8,155 +8,145 @@ import { QuoteFilters } from '~/utils/quoteFilters';
import { useTranslation } from 'react-i18next';
interface ActiveQuoteFilterChipsProps {
filters: QuoteFilters;
onRemoveFilter: (category: keyof QuoteFilters, value: string) => void;
onClearAll: () => void;
filters: QuoteFilters;
onRemoveFilter: (category: keyof QuoteFilters, value: string) => void;
onClearAll: () => void;
}
// Label mappings for filter values
const FILTER_LABELS: Record<string, string> = {
// Time Periods
ancient: 'Antike',
medieval: 'Mittelalter',
earlyModern: 'Frühe Neuzeit',
'19th': '19. Jh.',
'early20th': 'Frühes 20. Jh.',
'late20th': 'Spätes 20. Jh.',
'21st': '21. Jh.',
// Time Periods
ancient: 'Antike',
medieval: 'Mittelalter',
earlyModern: 'Frühe Neuzeit',
'19th': '19. Jh.',
early20th: 'Frühes 20. Jh.',
late20th: 'Spätes 20. Jh.',
'21st': '21. Jh.',
// Source Types
books: 'Bücher',
letters: 'Briefe',
speeches: 'Reden',
interviews: 'Interviews',
attributed: 'Zugeschrieben',
folkWisdom: 'Volksweisheit',
verified: 'Verifiziert',
// Source Types
books: 'Bücher',
letters: 'Briefe',
speeches: 'Reden',
interviews: 'Interviews',
attributed: 'Zugeschrieben',
folkWisdom: 'Volksweisheit',
verified: 'Verifiziert',
// Categories
wisdom: 'Weisheit',
motivation: 'Motivation',
love: 'Liebe',
science: 'Wissenschaft',
philosophy: 'Philosophie',
humor: 'Humor',
success: 'Erfolg',
change: 'Veränderung',
creativity: 'Kreativität',
courage: 'Mut',
happiness: 'Glück',
life: 'Leben',
// Categories
wisdom: 'Weisheit',
motivation: 'Motivation',
love: 'Liebe',
science: 'Wissenschaft',
philosophy: 'Philosophie',
humor: 'Humor',
success: 'Erfolg',
change: 'Veränderung',
creativity: 'Kreativität',
courage: 'Mut',
happiness: 'Glück',
life: 'Leben',
// Special
featured: 'Featured',
hasYear: 'Mit Jahr',
hasSource: 'Mit Quelle',
long: 'Lang',
short: 'Kurz',
// Special
featured: 'Featured',
hasYear: 'Mit Jahr',
hasSource: 'Mit Quelle',
long: 'Lang',
short: 'Kurz',
};
export function ActiveQuoteFilterChips({
filters,
onRemoveFilter,
onClearAll
filters,
onRemoveFilter,
onClearAll,
}: ActiveQuoteFilterChipsProps) {
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
// Collect all active filters
const activeFilters: Array<{ category: keyof QuoteFilters; value: string; label: string }> = [];
// Collect all active filters
const activeFilters: Array<{ category: keyof QuoteFilters; value: string; label: string }> = [];
Object.entries(filters).forEach(([category, values]) => {
values.forEach(value => {
activeFilters.push({
category: category as keyof QuoteFilters,
value,
label: FILTER_LABELS[value] || value
});
});
});
Object.entries(filters).forEach(([category, values]) => {
values.forEach((value) => {
activeFilters.push({
category: category as keyof QuoteFilters,
value,
label: FILTER_LABELS[value] || value,
});
});
});
if (activeFilters.length === 0) {
return null;
}
if (activeFilters.length === 0) {
return null;
}
return (
<View style={{
paddingHorizontal: 16,
paddingTop: 110, // Account for header
paddingBottom: 8,
backgroundColor: isDarkMode ? '#000' : '#fff',
}}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
{/* Active filter chips */}
{activeFilters.map((filter, index) => (
<Pressable
key={`${filter.category}-${filter.value}-${index}`}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onRemoveFilter(filter.category, filter.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 12,
paddingRight: 8,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: 'rgba(124, 58, 237, 0.15)',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.3)',
}}
>
<Text
variant="caption"
weight="medium"
style={{ color: '#7c3aed', marginRight: 4 }}
>
{filter.label}
</Text>
<Icon
name="close-circle"
size={16}
color="#7c3aed"
/>
</Pressable>
))}
return (
<View
style={{
paddingHorizontal: 16,
paddingTop: 110, // Account for header
paddingBottom: 8,
backgroundColor: isDarkMode ? '#000' : '#fff',
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
{/* Active filter chips */}
{activeFilters.map((filter, index) => (
<Pressable
key={`${filter.category}-${filter.value}-${index}`}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onRemoveFilter(filter.category, filter.value);
}}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 12,
paddingRight: 8,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: 'rgba(124, 58, 237, 0.15)',
borderWidth: 1,
borderColor: 'rgba(124, 58, 237, 0.3)',
}}
>
<Text variant="caption" weight="medium" style={{ color: '#7c3aed', marginRight: 4 }}>
{filter.label}
</Text>
<Icon name="close-circle" size={16} color="#7c3aed" />
</Pressable>
))}
{/* Clear all button */}
{activeFilters.length > 1 && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)',
borderWidth: 1,
borderColor: isDarkMode ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)',
}}
>
<Text
variant="caption"
weight="semibold"
style={{ color: '#ef4444' }}
>
{t('common.clearAll')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
{/* Clear all button */}
{activeFilters.length > 1 && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(239, 68, 68, 0.1)',
borderWidth: 1,
borderColor: isDarkMode ? 'rgba(239, 68, 68, 0.3)' : 'rgba(239, 68, 68, 0.2)',
}}
>
<Text variant="caption" weight="semibold" style={{ color: '#ef4444' }}>
{t('common.clearAll')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}

View file

@ -10,287 +10,280 @@ import { QuoteFilters } from '~/utils/quoteFilters';
import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet';
interface QuoteFilterSheetProps {
bottomSheetRef: React.RefObject<BottomSheet>;
filters: QuoteFilters;
onFiltersChange: (filters: QuoteFilters) => void;
onClearAll: () => void;
bottomSheetRef: React.RefObject<BottomSheet>;
filters: QuoteFilters;
onFiltersChange: (filters: QuoteFilters) => void;
onClearAll: () => void;
}
const TIME_PERIOD_OPTIONS = [
{ key: 'ancient', label: 'Antike', description: '< 0' },
{ key: 'medieval', label: 'Mittelalter', description: '0-1500' },
{ key: 'earlyModern', label: 'Frühe Neuzeit', description: '1500-1800' },
{ key: '19th', label: '19. Jh.', description: '1800-1900' },
{ key: 'early20th', label: 'Frühes 20. Jh.', description: '1900-1950' },
{ key: 'late20th', label: 'Spätes 20. Jh.', description: '1950-2000' },
{ key: '21st', label: '21. Jh.', description: '2000+' },
{ key: 'ancient', label: 'Antike', description: '< 0' },
{ key: 'medieval', label: 'Mittelalter', description: '0-1500' },
{ key: 'earlyModern', label: 'Frühe Neuzeit', description: '1500-1800' },
{ key: '19th', label: '19. Jh.', description: '1800-1900' },
{ key: 'early20th', label: 'Frühes 20. Jh.', description: '1900-1950' },
{ key: 'late20th', label: 'Spätes 20. Jh.', description: '1950-2000' },
{ key: '21st', label: '21. Jh.', description: '2000+' },
];
const SOURCE_TYPE_OPTIONS = [
{ key: 'books', label: 'Bücher/Werke' },
{ key: 'letters', label: 'Briefe' },
{ key: 'speeches', label: 'Reden' },
{ key: 'interviews', label: 'Interviews' },
{ key: 'attributed', label: 'Zugeschrieben' },
{ key: 'folkWisdom', label: 'Volksweisheit' },
{ key: 'verified', label: 'Verifiziert' },
{ key: 'books', label: 'Bücher/Werke' },
{ key: 'letters', label: 'Briefe' },
{ key: 'speeches', label: 'Reden' },
{ key: 'interviews', label: 'Interviews' },
{ key: 'attributed', label: 'Zugeschrieben' },
{ key: 'folkWisdom', label: 'Volksweisheit' },
{ key: 'verified', label: 'Verifiziert' },
];
const CATEGORY_OPTIONS = [
{ key: 'wisdom', label: 'Weisheit' },
{ key: 'motivation', label: 'Motivation' },
{ key: 'love', label: 'Liebe' },
{ key: 'science', label: 'Wissenschaft' },
{ key: 'philosophy', label: 'Philosophie' },
{ key: 'humor', label: 'Humor' },
{ key: 'success', label: 'Erfolg' },
{ key: 'change', label: 'Veränderung' },
{ key: 'creativity', label: 'Kreativität' },
{ key: 'courage', label: 'Mut' },
{ key: 'happiness', label: 'Glück' },
{ key: 'life', label: 'Leben' },
{ key: 'wisdom', label: 'Weisheit' },
{ key: 'motivation', label: 'Motivation' },
{ key: 'love', label: 'Liebe' },
{ key: 'science', label: 'Wissenschaft' },
{ key: 'philosophy', label: 'Philosophie' },
{ key: 'humor', label: 'Humor' },
{ key: 'success', label: 'Erfolg' },
{ key: 'change', label: 'Veränderung' },
{ key: 'creativity', label: 'Kreativität' },
{ key: 'courage', label: 'Mut' },
{ key: 'happiness', label: 'Glück' },
{ key: 'life', label: 'Leben' },
];
const AUTHOR_ERA_OPTIONS = [
{ key: 'ancient', label: 'Antike' },
{ key: 'medieval', label: 'Mittelalter' },
{ key: 'earlyModern', label: 'Frühe Neuzeit' },
{ key: '19th', label: '19. Jahrhundert' },
{ key: '20th', label: '20. Jahrhundert' },
{ key: '21st', label: '21. Jahrhundert' },
{ key: 'ancient', label: 'Antike' },
{ key: 'medieval', label: 'Mittelalter' },
{ key: 'earlyModern', label: 'Frühe Neuzeit' },
{ key: '19th', label: '19. Jahrhundert' },
{ key: '20th', label: '20. Jahrhundert' },
{ key: '21st', label: '21. Jahrhundert' },
];
const SPECIAL_OPTIONS = [
{ key: 'featured', label: 'Featured' },
{ key: 'hasYear', label: 'Mit Jahreszahl' },
{ key: 'hasSource', label: 'Mit Quelle' },
{ key: 'verified', label: 'Verifiziert' },
{ key: 'long', label: 'Lang (>150 Zeichen)' },
{ key: 'short', label: 'Kurz (<100 Zeichen)' },
{ key: 'featured', label: 'Featured' },
{ key: 'hasYear', label: 'Mit Jahreszahl' },
{ key: 'hasSource', label: 'Mit Quelle' },
{ key: 'verified', label: 'Verifiziert' },
{ key: 'long', label: 'Lang (>150 Zeichen)' },
{ key: 'short', label: 'Kurz (<100 Zeichen)' },
];
export function QuoteFilterSheet({
bottomSheetRef,
filters,
onFiltersChange,
onClearAll
bottomSheetRef,
filters,
onFiltersChange,
onClearAll,
}: QuoteFilterSheetProps) {
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const snapPoints = useMemo(() => ['65%', '85%'], []);
const { t } = useTranslation();
const isDarkMode = useIsDarkMode();
const snapPoints = useMemo(() => ['65%', '85%'], []);
const toggleFilter = (category: keyof QuoteFilters, value: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const toggleFilter = (category: keyof QuoteFilters, value: string) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const currentFilters = filters[category];
const newFilters = currentFilters.includes(value)
? currentFilters.filter(v => v !== value)
: [...currentFilters, value];
const currentFilters = filters[category];
const newFilters = currentFilters.includes(value)
? currentFilters.filter((v) => v !== value)
: [...currentFilters, value];
onFiltersChange({
...filters,
[category]: newFilters
});
};
onFiltersChange({
...filters,
[category]: newFilters,
});
};
const hasActiveFilters = Object.values(filters).some(arr => arr.length > 0);
const hasActiveFilters = Object.values(filters).some((arr) => arr.length > 0);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
),
[]
);
const renderFilterChip = (
category: keyof QuoteFilters,
option: { key: string; label: string; description?: string }
) => {
const isActive = filters[category].includes(option.key);
const renderFilterChip = (
category: keyof QuoteFilters,
option: { key: string; label: string; description?: string }
) => {
const isActive = filters[category].includes(option.key);
return (
<Pressable
key={option.key}
onPress={() => toggleFilter(category, option.key)}
style={[
styles.chip,
{
backgroundColor: isActive
? 'rgba(124, 58, 237, 0.2)'
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
borderWidth: isActive ? 1.5 : 1,
borderColor: isActive
? '#7c3aed'
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}
]}
>
<View style={styles.chipContent}>
{isActive && (
<Icon
name="checkmark-circle"
size={16}
color="#7c3aed"
style={{ marginRight: 6 }}
/>
)}
<Text
variant="bodySmall"
weight={isActive ? 'semibold' : 'medium'}
style={{ color: isActive ? '#7c3aed' : isDarkMode ? '#fff' : '#000' }}
>
{option.label}
</Text>
{option.description && (
<Text
variant="caption"
style={{
color: isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
marginLeft: 4
}}
>
{option.description}
</Text>
)}
</View>
</Pressable>
);
};
return (
<Pressable
key={option.key}
onPress={() => toggleFilter(category, option.key)}
style={[
styles.chip,
{
backgroundColor: isActive
? 'rgba(124, 58, 237, 0.2)'
: isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.05)',
borderWidth: isActive ? 1.5 : 1,
borderColor: isActive
? '#7c3aed'
: isDarkMode
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.1)',
},
]}
>
<View style={styles.chipContent}>
{isActive && (
<Icon name="checkmark-circle" size={16} color="#7c3aed" style={{ marginRight: 6 }} />
)}
<Text
variant="bodySmall"
weight={isActive ? 'semibold' : 'medium'}
style={{ color: isActive ? '#7c3aed' : isDarkMode ? '#fff' : '#000' }}
>
{option.label}
</Text>
{option.description && (
<Text
variant="caption"
style={{
color: isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)',
marginLeft: 4,
}}
>
{option.description}
</Text>
)}
</View>
</Pressable>
);
};
const renderFilterSection = (
title: string,
category: keyof QuoteFilters,
options: { key: string; label: string; description?: string }[]
) => (
<View style={styles.section}>
<Text
variant="label"
weight="semibold"
color="secondary"
style={styles.sectionTitle}
>
{title}
</Text>
<View style={styles.chipContainer}>
{options.map(option => renderFilterChip(category, option))}
</View>
</View>
);
const renderFilterSection = (
title: string,
category: keyof QuoteFilters,
options: { key: string; label: string; description?: string }[]
) => (
<View style={styles.section}>
<Text variant="label" weight="semibold" color="secondary" style={styles.sectionTitle}>
{title}
</Text>
<View style={styles.chipContainer}>
{options.map((option) => renderFilterChip(category, option))}
</View>
</View>
);
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose={true}
enableOverDrag={false}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
}}
handleIndicatorStyle={{
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)',
width: 40,
height: 4,
}}
animateOnMount={true}
>
<View style={[styles.header, {
borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
}]}>
<View style={{ flex: 1 }}>
<Text variant="title" weight="bold" color="primary">
Filter
</Text>
{hasActiveFilters && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{ marginTop: 4 }}
>
<Text variant="caption" style={{ color: '#ef4444' }}>
Alle zurücksetzen
</Text>
</Pressable>
)}
</View>
return (
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={snapPoints}
enablePanDownToClose={true}
enableOverDrag={false}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: isDarkMode ? '#1a1a1a' : '#ffffff',
}}
handleIndicatorStyle={{
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)',
width: 40,
height: 4,
}}
animateOnMount={true}
>
<View
style={[
styles.header,
{
borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
},
]}
>
<View style={{ flex: 1 }}>
<Text variant="title" weight="bold" color="primary">
Filter
</Text>
{hasActiveFilters && (
<Pressable
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClearAll();
}}
style={{ marginTop: 4 }}
>
<Text variant="caption" style={{ color: '#ef4444' }}>
Alle zurücksetzen
</Text>
</Pressable>
)}
</View>
<Pressable
onPress={() => bottomSheetRef.current?.close()}
style={[styles.closeButton, {
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
}]}
>
<Icon
name="close"
size={20}
color={isDarkMode ? '#fff' : '#000'}
/>
</Pressable>
</View>
<Pressable
onPress={() => bottomSheetRef.current?.close()}
style={[
styles.closeButton,
{
backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)',
},
]}
>
<Icon name="close" size={20} color={isDarkMode ? '#fff' : '#000'} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{renderFilterSection('Zeitperiode', 'timePeriods', TIME_PERIOD_OPTIONS)}
{renderFilterSection('Quellentyp', 'sourceTypes', SOURCE_TYPE_OPTIONS)}
{renderFilterSection('Kategorien', 'categories', CATEGORY_OPTIONS)}
{renderFilterSection('Autoren-Epoche', 'authorEras', AUTHOR_ERA_OPTIONS)}
{renderFilterSection('Besondere', 'special', SPECIAL_OPTIONS)}
</BottomSheetScrollView>
</BottomSheet>
);
<BottomSheetScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{renderFilterSection('Zeitperiode', 'timePeriods', TIME_PERIOD_OPTIONS)}
{renderFilterSection('Quellentyp', 'sourceTypes', SOURCE_TYPE_OPTIONS)}
{renderFilterSection('Kategorien', 'categories', CATEGORY_OPTIONS)}
{renderFilterSection('Autoren-Epoche', 'authorEras', AUTHOR_ERA_OPTIONS)}
{renderFilterSection('Besondere', 'special', SPECIAL_OPTIONS)}
</BottomSheetScrollView>
</BottomSheet>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
borderBottomWidth: 1,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
content: {
padding: 20,
paddingBottom: 40,
},
section: {
marginBottom: 24,
},
sectionTitle: {
marginBottom: 12,
marginLeft: 4,
},
chipContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
chip: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
marginRight: 8,
marginBottom: 8,
},
chipContent: {
flexDirection: 'row',
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 16,
borderBottomWidth: 1,
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
content: {
padding: 20,
paddingBottom: 40,
},
section: {
marginBottom: 24,
},
sectionTitle: {
marginBottom: 12,
marginLeft: 4,
},
chipContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
chip: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
marginRight: 8,
marginBottom: 8,
},
chipContent: {
flexDirection: 'row',
alignItems: 'center',
},
});

View file

@ -2,18 +2,18 @@
// List item spacing
export const LIST_ITEM_SPACING = {
horizontal: 16, // px-4 in Tailwind = 16px
vertical: 20, // mb-5 in Tailwind = 20px - größerer Abstand
horizontal: 16, // px-4 in Tailwind = 16px
vertical: 20, // mb-5 in Tailwind = 20px - größerer Abstand
};
// Padding for list containers
export const LIST_CONTAINER_PADDING = {
top: 140, // Increased space for header with blur to prevent cards from touching header
bottom: 160, // Space for segmented controls and tab bar
top: 140, // Increased space for header with blur to prevent cards from touching header
bottom: 160, // Space for segmented controls and tab bar
};
// Tailwind class names for consistent spacing
export const LIST_ITEM_CLASSES = {
wrapper: 'px-4 mb-5', // Horizontal padding and bottom margin - größerer Abstand mb-5
wrapperNoMargin: 'px-4', // Just horizontal padding
};
wrapper: 'px-4 mb-5', // Horizontal padding and bottom margin - größerer Abstand mb-5
wrapperNoMargin: 'px-4', // Just horizontal padding
};

View file

@ -7,12 +7,12 @@
* - Maintain consistency across the app
*/
export const STORAGE_KEYS = {
QUOTES: 'quotes-storage',
LISTS: 'list-storage',
SETTINGS: 'settings-storage',
PREMIUM: 'premium-storage',
ONBOARDING: 'onboarding-storage',
QUOTES: 'quotes-storage',
LISTS: 'list-storage',
SETTINGS: 'settings-storage',
PREMIUM: 'premium-storage',
ONBOARDING: 'onboarding-storage',
} as const;
// Type for storage keys
export type StorageKey = typeof STORAGE_KEYS[keyof typeof STORAGE_KEYS];
export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];

View file

@ -8,93 +8,89 @@ import { useListStore } from '~/store/listStore';
import usePremiumStore from '~/store/premiumStore';
interface ListCreationResult {
success: boolean;
id?: string;
error?: 'limit_reached' | 'creation_failed';
success: boolean;
id?: string;
error?: 'limit_reached' | 'creation_failed';
}
export function useListCreation() {
const { createList: createListInStore, lists } = useListStore();
const { isPremium, canCreateCollection, createCollection } = usePremiumStore();
const { createList: createListInStore, lists } = useListStore();
const { isPremium, canCreateCollection, createCollection } = usePremiumStore();
/**
* Create list with premium validation
*/
const createList = (
name: string,
description?: string,
color?: string
): ListCreationResult => {
// Count user-created lists (exclude default ones)
const userLists = lists.filter(p => !p.isDefault);
/**
* Create list with premium validation
*/
const createList = (name: string, description?: string, color?: string): ListCreationResult => {
// Count user-created lists (exclude default ones)
const userLists = lists.filter((p) => !p.isDefault);
// Free users get:
// - Default list (📚 Eigene Liste) for free
// - First user-created list for free (userLists.length === 0)
// - Additional lists require premium or use weekly quota (1 per week)
if (!isPremium && userLists.length >= 1) {
// Check if user can create another collection (using weekly quota)
if (!canCreateCollection()) {
return {
success: false,
error: 'limit_reached'
};
}
// Free users get:
// - Default list (📚 Eigene Liste) for free
// - First user-created list for free (userLists.length === 0)
// - Additional lists require premium or use weekly quota (1 per week)
if (!isPremium && userLists.length >= 1) {
// Check if user can create another collection (using weekly quota)
if (!canCreateCollection()) {
return {
success: false,
error: 'limit_reached',
};
}
// Track collection creation (only for 2nd+ user-created list)
createCollection();
}
// Track collection creation (only for 2nd+ user-created list)
createCollection();
}
// Create list in store
const id = createListInStore(name, description, color);
// Create list in store
const id = createListInStore(name, description, color);
if (!id) {
return {
success: false,
error: 'creation_failed'
};
}
if (!id) {
return {
success: false,
error: 'creation_failed',
};
}
return {
success: true,
id
};
};
return {
success: true,
id,
};
};
/**
* Check if user can create a list
*/
const canCreateList = (): boolean => {
if (isPremium) return true;
/**
* Check if user can create a list
*/
const canCreateList = (): boolean => {
if (isPremium) return true;
const userLists = lists.filter(p => !p.isDefault);
const userLists = lists.filter((p) => !p.isDefault);
// First user-created list is always free
if (userLists.length === 0) return true;
// First user-created list is always free
if (userLists.length === 0) return true;
// Additional lists require quota
return canCreateCollection();
};
// Additional lists require quota
return canCreateCollection();
};
/**
* Get remaining lists user can create
*/
const getRemainingLists = (): number => {
if (isPremium) return Infinity;
/**
* Get remaining lists user can create
*/
const getRemainingLists = (): number => {
if (isPremium) return Infinity;
const userLists = lists.filter(p => !p.isDefault);
const userLists = lists.filter((p) => !p.isDefault);
// First list is always available
if (userLists.length === 0) return 1;
// First list is always available
if (userLists.length === 0) return 1;
// Check weekly quota
const remainingCollections = usePremiumStore.getState().getRemainingCollections();
return Math.max(0, remainingCollections);
};
// Check weekly quota
const remainingCollections = usePremiumStore.getState().getRemainingCollections();
return Math.max(0, remainingCollections);
};
return {
createList,
canCreateList,
getRemainingLists
};
return {
createList,
canCreateList,
getRemainingLists,
};
}

View file

@ -10,120 +10,120 @@ import { useTranslation } from 'react-i18next';
import type { EnhancedQuote, Author } from '@quote/shared';
export function useShare() {
const { t } = useTranslation();
const { t } = useTranslation();
/**
* Share a quote
*/
const shareQuote = async (quote: EnhancedQuote) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
/**
* Share a quote
*/
const shareQuote = async (quote: EnhancedQuote) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const quoteText = `"${quote.text}"\n\n— ${quote.author?.name || t('quotes.unknown')}`;
const quoteText = `"${quote.text}"\n\n— ${quote.author?.name || t('quotes.unknown')}`;
const result = await Share.share({
message: quoteText,
title: t('quotes.shareTitle'),
});
const result = await Share.share({
message: quoteText,
title: t('quotes.shareTitle'),
});
if (result.action === Share.sharedAction) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
Alert.alert(t('quotes.shareError'), t('quotes.shareErrorMessage'));
}
};
if (result.action === Share.sharedAction) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
Alert.alert(t('quotes.shareError'), t('quotes.shareErrorMessage'));
}
};
/**
* Share an author
*/
const shareAuthor = async (author: Author) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
/**
* Share an author
*/
const shareAuthor = async (author: Author) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}\n\n${author.biography?.short || author.biography?.long || ''}`;
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}\n\n${author.biography?.short || author.biography?.long || ''}`;
const result = await Share.share({
message: authorInfo,
title: author.name,
});
const result = await Share.share({
message: authorInfo,
title: author.name,
});
if (result.action === Share.sharedAction) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
Alert.alert(t('common.shareError'), t('common.shareErrorMessage'));
}
};
if (result.action === Share.sharedAction) {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
} catch (error) {
Alert.alert(t('common.shareError'), t('common.shareErrorMessage'));
}
};
/**
* Copy quote to clipboard
*/
const copyQuoteToClipboard = async (quote: EnhancedQuote) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
/**
* Copy quote to clipboard
*/
const copyQuoteToClipboard = async (quote: EnhancedQuote) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const quoteText = `"${quote.text}"\n\n— ${quote.author?.name || t('quotes.unknown')}`;
await Clipboard.setStringAsync(quoteText);
const quoteText = `"${quote.text}"\n\n— ${quote.author?.name || t('quotes.unknown')}`;
await Clipboard.setStringAsync(quoteText);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
if (Platform.OS === 'ios') {
Alert.alert(t('quotes.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark'
});
}
} catch (error) {
Alert.alert(t('quotes.copyError'), t('quotes.copyErrorMessage'));
}
};
if (Platform.OS === 'ios') {
Alert.alert(t('quotes.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark',
});
}
} catch (error) {
Alert.alert(t('quotes.copyError'), t('quotes.copyErrorMessage'));
}
};
/**
* Copy author info to clipboard
*/
const copyAuthorToClipboard = async (author: Author) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
/**
* Copy author info to clipboard
*/
const copyAuthorToClipboard = async (author: Author) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}`;
await Clipboard.setStringAsync(authorInfo);
const authorInfo = `${author.name}${author.lifeYears ? ` (${author.lifeYears})` : ''}\n${author.profession?.join(', ') || ''}`;
await Clipboard.setStringAsync(authorInfo);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
if (Platform.OS === 'ios') {
Alert.alert(t('common.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark'
});
}
} catch (error) {
Alert.alert(t('common.copyError'), t('common.copyErrorMessage'));
}
};
if (Platform.OS === 'ios') {
Alert.alert(t('common.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark',
});
}
} catch (error) {
Alert.alert(t('common.copyError'), t('common.copyErrorMessage'));
}
};
/**
* Generic copy to clipboard
*/
const copyToClipboard = async (text: string, successMessage?: string) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await Clipboard.setStringAsync(text);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
/**
* Generic copy to clipboard
*/
const copyToClipboard = async (text: string, successMessage?: string) => {
try {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await Clipboard.setStringAsync(text);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
if (Platform.OS === 'ios') {
Alert.alert(successMessage || t('common.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark'
});
}
} catch (error) {
Alert.alert(t('common.copyError'), t('common.copyErrorMessage'));
}
};
if (Platform.OS === 'ios') {
Alert.alert(successMessage || t('common.copied'), '', [{ text: 'OK' }], {
userInterfaceStyle: 'dark',
});
}
} catch (error) {
Alert.alert(t('common.copyError'), t('common.copyErrorMessage'));
}
};
return {
shareQuote,
shareAuthor,
copyQuoteToClipboard,
copyAuthorToClipboard,
copyToClipboard,
};
return {
shareQuote,
shareAuthor,
copyQuoteToClipboard,
copyAuthorToClipboard,
copyToClipboard,
};
}

View file

@ -9,102 +9,110 @@ import { getTheme, getCategoryGradient, type ThemeColors } from '~/themes/defini
import type { ThemeType } from '~/store/settingsStore';
export interface UseThemeReturn {
colors: ThemeColors;
isDark: boolean;
themeType: ThemeType;
getCategoryGradient: (category?: string) => string[];
getCardGradient: () => string[];
getDailyCardGradient: () => string[];
getButtonGradient: (variant?: 'primary' | 'secondary') => string[];
colors: ThemeColors;
isDark: boolean;
themeType: ThemeType;
getCategoryGradient: (category?: string) => string[];
getCardGradient: () => string[];
getDailyCardGradient: () => string[];
getButtonGradient: (variant?: 'primary' | 'secondary') => string[];
}
/**
* Hook für Zugriff auf das aktuelle Theme
*/
export function useTheme(): UseThemeReturn {
const isDark = useIsDarkMode();
const themeType = useThemeType();
const colors = useMemo(() => {
const themeColors = getTheme(themeType, isDark);
return themeColors;
}, [themeType, isDark]);
const getCategoryGradientForTheme = useMemo(() => {
return (category?: string) => {
const gradient = getCategoryGradient(themeType, isDark, category);
return gradient;
};
}, [themeType, isDark]);
const getCardGradient = useMemo(() => {
return () => colors.cardGradient;
}, [colors.cardGradient]);
const getDailyCardGradient = useMemo(() => {
return () => colors.dailyCardGradient;
}, [colors.dailyCardGradient]);
const getButtonGradient = useMemo(() => {
return (variant: 'primary' | 'secondary' = 'primary') => {
return variant === 'primary' ? colors.buttonPrimary : colors.buttonSecondary;
};
}, [colors.buttonPrimary, colors.buttonSecondary]);
return {
colors,
isDark,
themeType,
getCategoryGradient: getCategoryGradientForTheme,
getCardGradient,
getDailyCardGradient,
getButtonGradient,
};
const isDark = useIsDarkMode();
const themeType = useThemeType();
const colors = useMemo(() => {
const themeColors = getTheme(themeType, isDark);
return themeColors;
}, [themeType, isDark]);
const getCategoryGradientForTheme = useMemo(() => {
return (category?: string) => {
const gradient = getCategoryGradient(themeType, isDark, category);
return gradient;
};
}, [themeType, isDark]);
const getCardGradient = useMemo(() => {
return () => colors.cardGradient;
}, [colors.cardGradient]);
const getDailyCardGradient = useMemo(() => {
return () => colors.dailyCardGradient;
}, [colors.dailyCardGradient]);
const getButtonGradient = useMemo(() => {
return (variant: 'primary' | 'secondary' = 'primary') => {
return variant === 'primary' ? colors.buttonPrimary : colors.buttonSecondary;
};
}, [colors.buttonPrimary, colors.buttonSecondary]);
return {
colors,
isDark,
themeType,
getCategoryGradient: getCategoryGradientForTheme,
getCardGradient,
getDailyCardGradient,
getButtonGradient,
};
}
/**
* Hook für Text-Farben basierend auf Variante
*/
export function useTextColor(variant: 'primary' | 'secondary' | 'tertiary' | 'accent' | 'danger' | 'success' | 'warning' = 'primary'): string {
const { colors } = useTheme();
return colors[variant];
export function useTextColor(
variant:
| 'primary'
| 'secondary'
| 'tertiary'
| 'accent'
| 'danger'
| 'success'
| 'warning' = 'primary'
): string {
const { colors } = useTheme();
return colors[variant];
}
/**
* Hook für Hintergrund-Farben
*/
export function useBackgroundColor(variant: 'background' | 'surface' = 'background'): string {
const { colors } = useTheme();
return variant === 'background' ? colors.background : colors.surface;
const { colors } = useTheme();
return variant === 'background' ? colors.background : colors.surface;
}
/**
* Hook für Border-Farben
*/
export function useBorderColor(): string {
const { colors } = useTheme();
return colors.border;
const { colors } = useTheme();
return colors.border;
}
/**
* Utility-Funktion für Gradient-Strings in Tailwind-Format
*/
export function gradientToTailwind(gradient: string[]): string {
if (gradient.length === 2) {
return `from-[${gradient[0]}] to-[${gradient[1]}]`;
}
if (gradient.length === 3) {
return `from-[${gradient[0]}] via-[${gradient[1]}] to-[${gradient[2]}]`;
}
return `from-[${gradient[0]}] to-[${gradient[0]}]`;
if (gradient.length === 2) {
return `from-[${gradient[0]}] to-[${gradient[1]}]`;
}
if (gradient.length === 3) {
return `from-[${gradient[0]}] via-[${gradient[1]}] to-[${gradient[2]}]`;
}
return `from-[${gradient[0]}] to-[${gradient[0]}]`;
}
/**
* Utility-Funktion für CSS Gradient-Strings
*/
export function gradientToCSS(gradient: string[], direction: string = 'to bottom right'): string {
return `linear-gradient(${direction}, ${gradient.join(', ')})`;
return `linear-gradient(${direction}, ${gradient.join(', ')})`;
}
export default useTheme;
export default useTheme;

View file

@ -16,47 +16,43 @@ const deviceLanguage = Localization.getLocales()[0]?.languageCode || 'de';
// Initialize i18n synchronously but handle errors
try {
i18n
.use(initReactI18next)
.init({
compatibilityJSON: 'v3',
resources: {
de: { translation: de },
en: { translation: en }
},
lng: deviceLanguage,
fallbackLng: 'de',
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
i18n.use(initReactI18next).init({
compatibilityJSON: 'v3',
resources: {
de: { translation: de },
en: { translation: en },
},
lng: deviceLanguage,
fallbackLng: 'de',
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
} catch (error) {
console.error('Failed to initialize i18n:', error);
// Fallback to German if initialization fails
i18n
.use(initReactI18next)
.init({
compatibilityJSON: 'v3',
resources: {
de: { translation: de }
},
lng: 'de',
fallbackLng: 'de',
interpolation: {
escapeValue: false
},
react: {
useSuspense: false
}
});
console.error('Failed to initialize i18n:', error);
// Fallback to German if initialization fails
i18n.use(initReactI18next).init({
compatibilityJSON: 'v3',
resources: {
de: { translation: de },
},
lng: 'de',
fallbackLng: 'de',
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
}
export default i18n;
// Helper function to change language
export const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
i18n.changeLanguage(lng);
};

View file

@ -1,3 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View file

@ -1,86 +1,86 @@
{
"name": "@quote/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"build:biographies": "npx tsx scripts/buildBiographies.ts",
"build:dev": "pnpm run build:biographies && eas build --profile development",
"build:preview": "pnpm run build:biographies && eas build --profile preview",
"build:prod": "pnpm run build:biographies && eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "pnpm run build:biographies && expo start --web"
},
"dependencies": {
"@quote/shared": "workspace:*",
"@anthropic-ai/sdk": "^0.65.0",
"@bacons/apple-targets": "^3.0.2",
"@expo/metro-runtime": "~6.1.2",
"@expo/ui": "~0.2.0-beta.4",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.0.3",
"expo": "~54.0.9",
"expo-blur": "~15.0.7",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.0",
"expo-dev-client": "~6.0.12",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.15",
"expo-font": "~14.0.8",
"expo-haptics": "~15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "^17.0.7",
"expo-router": "~6.0.8",
"expo-sharing": "~14.0.7",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"i18next": "^25.5.2",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.7.3",
"react-native": "0.81.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-purchases": "^9.5.1",
"react-native-reanimated": "4.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-shared-group-preferences": "^1.1.24",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.5.2",
"@types/react": "^19.1.0",
"ajv": "^8.12.0",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"gray-matter": "^4.0.3",
"jest": "^30.2.0",
"jest-expo": "^54.0.12",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.9.2"
},
"private": true
"name": "@quote/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"build:biographies": "npx tsx scripts/buildBiographies.ts",
"build:dev": "pnpm run build:biographies && eas build --profile development",
"build:preview": "pnpm run build:biographies && eas build --profile preview",
"build:prod": "pnpm run build:biographies && eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "pnpm run build:biographies && expo start --web"
},
"dependencies": {
"@quote/shared": "workspace:*",
"@anthropic-ai/sdk": "^0.65.0",
"@bacons/apple-targets": "^3.0.2",
"@expo/metro-runtime": "~6.1.2",
"@expo/ui": "~0.2.0-beta.4",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.0.3",
"expo": "~54.0.9",
"expo-blur": "~15.0.7",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.0",
"expo-dev-client": "~6.0.12",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.15",
"expo-font": "~14.0.8",
"expo-haptics": "~15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "^17.0.7",
"expo-router": "~6.0.8",
"expo-sharing": "~14.0.7",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"i18next": "^25.5.2",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.7.3",
"react-native": "0.81.4",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-gesture-handler": "~2.28.0",
"react-native-purchases": "^9.5.1",
"react-native-reanimated": "4.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-shared-group-preferences": "^1.1.24",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.5.2",
"@types/react": "^19.1.0",
"ajv": "^8.12.0",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"gray-matter": "^4.0.3",
"jest": "^30.2.0",
"jest-expo": "^54.0.12",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.9.2"
},
"private": true
}

View file

@ -8,27 +8,27 @@ import * as path from 'path';
import { Author } from '../services/contentLoader';
function loadAuthors(lang: 'de' | 'en'): Author[] {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
}
function writeAuthors(authors: Author[], lang: 'de' | 'en'): void {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
`;
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
}
const batch1Bios: Record<string, string> = {
'aristotle': `# Aristotle
aristotle: `# Aristotle
*384 - 322 v. Chr.*
## Kurzbiografie
@ -268,7 +268,7 @@ Seine Methode - Beobachtung, Klassifikation, systematische Darstellung - prägte
Als Universalgelehrter verkörperte er ein Ideal von Bildung, das die gesamte Wirklichkeit umfassen wollte. Die Einheit seines Systems, das von der Logik über die Naturwissenschaften bis zur Ethik und Politik reicht, bleibt beeindruckend.`,
'hesse-hermann': `# Hermann Hesse
'hesse-hermann': `# Hermann Hesse
*2. Juli 1877 - 9. August 1962*
## Kurzbiografie
@ -461,7 +461,7 @@ Seine Romane sind Wegbegleiter für Suchende, Außenseiter und alle, die nach ei
Hesses Größe liegt in der Ehrlichkeit, mit der er eigene Krisen darstellte, und im Mut, spirituelle Fragen in einer säkularen Zeit zu stellen. Er lebte das Künstlerleben, das er besang, und zahlte den Preis der Einsamkeit für seine Unabhängigkeit.`,
'marcus-aurelius': `# Marcus Aurelius
'marcus-aurelius': `# Marcus Aurelius
*26. April 121 - 17. März 180 n. Chr.*
## Kurzbiografie
@ -670,48 +670,48 @@ In einer Zeit, da das römische Reich bereits Risse zeigte, hielt er durch Chara
};
async function main() {
console.log('📝 Batch 1: Featured Autoren\n');
console.log('📝 Batch 1: Featured Autoren\n');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
let updated = 0;
let updated = 0;
const updatedDE = authorsDE.map(author => {
if (batch1Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch1Bios[author.id]
}
};
}
return author;
});
const updatedDE = authorsDE.map((author) => {
if (batch1Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch1Bios[author.id],
},
};
}
return author;
});
const updatedEN = authorsEN.map(author => {
const deAuthor = updatedDE.find(a => a.id === author.id);
if (deAuthor?.biography?.long && batch1Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long
}
};
}
return author;
});
const updatedEN = authorsEN.map((author) => {
const deAuthor = updatedDE.find((a) => a.id === author.id);
if (deAuthor?.biography?.long && batch1Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long,
},
};
}
return author;
});
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
console.log('\n✨ Batch 1 fertig!\n');
console.log('\n✨ Batch 1 fertig!\n');
}
main();

View file

@ -8,27 +8,27 @@ import * as path from 'path';
import { Author } from '../services/contentLoader';
function loadAuthors(lang: 'de' | 'en'): Author[] {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
}
function writeAuthors(authors: Author[], lang: 'de' | 'en'): void {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
`;
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
}
const batch2Bios: Record<string, string> = {
'epiktet': `# Epiktet
epiktet: `# Epiktet
*ca. 50 - ca. 138 n. Chr.*
## Kurzbiografie
@ -284,7 +284,7 @@ Das Encheiridion ist vielleicht der beste Beweis, dass Philosophie keine akademi
Sein Einfluss reicht von Marc Aurel bis zur modernen Psychotherapie. Wann immer Menschen lernen müssen, mit Unverfügbarem umzugehen, bleibt Epiktet aktuell. In unsicheren Zeiten ist sein Rat zeitlos: Konzentriere dich auf das, was du ändern kannst, und akzeptiere, was du nicht ändern kannst.`,
'coelho-paulo': `# Paulo Coelho
'coelho-paulo': `# Paulo Coelho
*24. August 1947 - heute*
## Kurzbiografie
@ -490,7 +490,7 @@ Seine Größe liegt nicht in literarischer Kunstfertigkeit (die Kritiker vermiss
Ob man seine Philosophie teilt oder nicht: Der Einfluss ist unbestreitbar. Ganze Generationen wurden von ihm inspiriert, ihre "persönliche Legende" zu suchen. In einer desillusionierenden Welt bietet er Hoffnung - und das ist vielleicht wichtiger als literarische Perfektion.`,
'gibran-khalil': `# Khalil Gibran
'gibran-khalil': `# Khalil Gibran
*6. Januar 1883 - 10. April 1931*
## Kurzbiografie
@ -695,48 +695,48 @@ In einer fragmentierten Welt bot er die Vision der Einheit. In einer Zeit des Ma
};
async function main() {
console.log('📝 Batch 2: Featured Autoren\n');
console.log('📝 Batch 2: Featured Autoren\n');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
let updated = 0;
let updated = 0;
const updatedDE = authorsDE.map(author => {
if (batch2Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch2Bios[author.id]
}
};
}
return author;
});
const updatedDE = authorsDE.map((author) => {
if (batch2Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch2Bios[author.id],
},
};
}
return author;
});
const updatedEN = authorsEN.map(author => {
const deAuthor = updatedDE.find(a => a.id === author.id);
if (deAuthor?.biography?.long && batch2Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long
}
};
}
return author;
});
const updatedEN = authorsEN.map((author) => {
const deAuthor = updatedDE.find((a) => a.id === author.id);
if (deAuthor?.biography?.long && batch2Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long,
},
};
}
return author;
});
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
console.log('\n✨ Batch 2 fertig!\n');
console.log('\n✨ Batch 2 fertig!\n');
}
main();

View file

@ -8,27 +8,27 @@ import * as path from 'path';
import { Author } from '../services/contentLoader';
function loadAuthors(lang: 'de' | 'en'): Author[] {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
}
function writeAuthors(authors: Author[], lang: 'de' | 'en'): void {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
`;
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
}
const batch3Bios: Record<string, string> = {
'lee-bruce': `# Bruce Lee
'lee-bruce': `# Bruce Lee
*27. November 1940 - 20. Juli 1973*
## Kurzbiografie
@ -259,7 +259,7 @@ Seine Größe lag in der Verbindung scheinbar unvereinbarer Welten: Tradition un
Der frühe Tod verwandelte eine aufsteigende Karriere in einen Mythos. Aber auch ohne Tod wäre sein Einfluss revolutionär gewesen. Bruce Lee lehrte, dass Grenzen - kulturelle, physische, philosophische - nur im Kopf existieren. Seine Botschaft bleibt zeitlos: Sei du selbst, aber die beste Version davon.`,
'chaplin-charlie': `# Charlie Chaplin
'chaplin-charlie': `# Charlie Chaplin
*16. April 1889 - 25. Dezember 1977*
## Kurzbiografie
@ -445,7 +445,7 @@ Von den Londoner Slums zum Weltstar, von Hollywood-Liebling zum politischen Flü
Der Tramp watschelt durch die Geschichte - Symbol für alle, die trotz allem weitermachen, lächeln und hoffen. Das ist Charlie Chaplins unsterbliches Geschenk.`,
'ben-gurion-david': `# David Ben-Gurion
'ben-gurion-david': `# David Ben-Gurion
*16. Oktober 1886 - 1. Dezember 1973*
## Kurzbiografie
@ -666,7 +666,7 @@ Ohne Ben-Gurion kein Israel in 1948. Seine Willenskraft, strategische Intelligen
Israel heute - demokratisch, wirtschaftlich erfolgreich, militärisch stark, aber auch im Konflikt - trägt Ben-Gurions Stempel: Seine Stärken UND seine Ambivalenzen. Das Urteil über ihn bleibt komplex, wie der Staat, den er schuf.`,
'rousseau-jean': `# Jean-Jacques Rousseau
'rousseau-jean': `# Jean-Jacques Rousseau
*28. Juni 1712 - 2. Juli 1778*
## Kurzbiografie
@ -921,48 +921,48 @@ Seine zentrale Einsicht bleibt: Moderne Gesellschaft entfremdet. Freiheit erford
};
async function main() {
console.log('📝 Batch 3: Bruce Lee, Charlie Chaplin, Ben-Gurion, Rousseau\n');
console.log('📝 Batch 3: Bruce Lee, Charlie Chaplin, Ben-Gurion, Rousseau\n');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
let updated = 0;
let updated = 0;
const updatedDE = authorsDE.map(author => {
if (batch3Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch3Bios[author.id]
}
};
}
return author;
});
const updatedDE = authorsDE.map((author) => {
if (batch3Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch3Bios[author.id],
},
};
}
return author;
});
const updatedEN = authorsEN.map(author => {
const deAuthor = updatedDE.find(a => a.id === author.id);
if (deAuthor?.biography?.long && batch3Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long
}
};
}
return author;
});
const updatedEN = authorsEN.map((author) => {
const deAuthor = updatedDE.find((a) => a.id === author.id);
if (deAuthor?.biography?.long && batch3Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long,
},
};
}
return author;
});
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
console.log('\n✨ Batch 3 fertig!\n');
console.log('\n✨ Batch 3 fertig!\n');
}
main();

View file

@ -8,27 +8,27 @@ import * as path from 'path';
import { Author } from '../services/contentLoader';
function loadAuthors(lang: 'de' | 'en'): Author[] {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
}
function writeAuthors(authors: Author[], lang: 'de' | 'en'): void {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
`;
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
}
const batch4Bios: Record<string, string> = {
'johannes-paul-ii': `# Johannes Paul II.
'johannes-paul-ii': `# Johannes Paul II.
*18. Mai 1920 - 2. April 2005*
## Kurzbiografie
@ -266,7 +266,7 @@ Die Figur Johannes Paul II. zeigt: Auch Heilige sind Menschen. Größe und Versa
Er prägte das Papsttum neu: Reisend, medial, charismatisch. Franziskus ist anders, aber ohne Johannes Paul undenkbar.`,
'rowling-jk': `# J.K. Rowling
'rowling-jk': `# J.K. Rowling
*31. Juli 1965 - heute*
## Kurzbiografie
@ -471,7 +471,7 @@ Ihre Geschichte ist shakespearean: Armut, Erfolg, Kontroverse. Ihr Werk wird ble
Rowlings größte Leistung: Sie brachte eine Generation zum Lesen. Hogwarts wurde zur zweiten Heimat für Millionen. Diese Magie ist unsterblich - egal, was auf Twitter passiert.`,
'adler-alfred': `# Alfred Adler
'adler-alfred': `# Alfred Adler
*7. Februar 1870 - 28. Mai 1937*
## Kurzbiografie
@ -678,7 +678,7 @@ Aber: Sein Einfluss ist riesig - oft unerkannt. Kognitive Therapie, humanistisch
Adlers Vision: Psychologie im Dienst der Menschlichkeit. Heilung durch Gemeinschaft. Erziehung zur Demokratie. In zerrissener Zeit aktueller denn je.`,
'kästner-erich': `# Erich Kästner
'kästner-erich': `# Erich Kästner
*23. Februar 1899 - 29. Juli 1974*
## Kurzbiografie
@ -850,48 +850,48 @@ Kästners Werke leben. "Emil" wird gelesen. "Doppelte Lottchen" verfilmt. Seine
};
async function main() {
console.log('📝 Batch 4: Johannes Paul II, Rowling, Adler, Kästner\n');
console.log('📝 Batch 4: Johannes Paul II, Rowling, Adler, Kästner\n');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
let updated = 0;
let updated = 0;
const updatedDE = authorsDE.map(author => {
if (batch4Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch4Bios[author.id]
}
};
}
return author;
});
const updatedDE = authorsDE.map((author) => {
if (batch4Bios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: batch4Bios[author.id],
},
};
}
return author;
});
const updatedEN = authorsEN.map(author => {
const deAuthor = updatedDE.find(a => a.id === author.id);
if (deAuthor?.biography?.long && batch4Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long
}
};
}
return author;
});
const updatedEN = authorsEN.map((author) => {
const deAuthor = updatedDE.find((a) => a.id === author.id);
if (deAuthor?.biography?.long && batch4Bios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long,
},
};
}
return author;
});
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
console.log('\n✨ Batch 4 fertig!\n');
console.log('\n✨ Batch 4 fertig!\n');
}
main();

View file

@ -9,30 +9,30 @@ import * as path from 'path';
import { Author } from '../services/contentLoader';
function loadAuthors(lang: 'de' | 'en'): Author[] {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const content = fs.readFileSync(filePath, 'utf-8');
const match = content.match(/export const authors[A-Z]{2}: Author\[\] = (\[[\s\S]*\]);/);
return eval(match[1]) as Author[];
}
function writeAuthors(authors: Author[], lang: 'de' | 'en'): void {
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const filePath = path.join(__dirname, `../services/data/authors/${lang}.ts`);
const backupPath = filePath.replace('.ts', `.backup-${Date.now()}.ts`);
fs.copyFileSync(filePath, backupPath);
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
const authorsJson = JSON.stringify(authors, null, 2);
const tsContent = `import { Author } from '../../contentLoader';
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
`;
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
fs.writeFileSync(filePath, tsContent, 'utf-8');
console.log(`${lang}.ts aktualisiert`);
}
// Ausführliche Biografien (hochwertig, nicht template-basiert)
const detailedBios: Record<string, string> = {
'augustinus': `# Aurelius Augustinus
augustinus: `# Aurelius Augustinus
*13. November 354 - 28. August 430*
## Kurzbiografie
@ -241,7 +241,7 @@ Augustinus bleibt der einflussreichste Kirchenvater des Westens:
Seine Größe liegt in der radikalen Ehrlichkeit, mit der er sein zerrissenes Ich offenbart, und in der Tiefe, mit der er die Paradoxien menschlicher Existenz durchdenkt. Augustinus lehrte die Gotteserkenntnis durch Selbsterkenntnis und die Selbsterkenntnis durch Gotteserkenntnis.`,
'kennedy-john-f': `# John Fitzgerald Kennedy
'kennedy-john-f': `# John Fitzgerald Kennedy
*29. Mai 1917 - 22. November 1963*
## Kurzbiografie
@ -428,48 +428,48 @@ Kennedy verkörperte den Optimismus und die Zuversicht einer Ära, die mit ihm z
};
async function main() {
console.log('📝 Füge ausführliche Biografien hinzu\n');
console.log('📝 Füge ausführliche Biografien hinzu\n');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
const authorsDE = loadAuthors('de');
const authorsEN = loadAuthors('en');
let updated = 0;
let updated = 0;
const updatedDE = authorsDE.map(author => {
if (detailedBios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: detailedBios[author.id]
}
};
}
return author;
});
const updatedDE = authorsDE.map((author) => {
if (detailedBios[author.id]) {
console.log(`${author.name} (${author.id})`);
updated++;
return {
...author,
biography: {
...author.biography,
long: detailedBios[author.id],
},
};
}
return author;
});
const updatedEN = authorsEN.map(author => {
const deAuthor = updatedDE.find(a => a.id === author.id);
if (deAuthor?.biography?.long && detailedBios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long
}
};
}
return author;
});
const updatedEN = authorsEN.map((author) => {
const deAuthor = updatedDE.find((a) => a.id === author.id);
if (deAuthor?.biography?.long && detailedBios[author.id]) {
return {
...author,
biography: {
...author.biography,
long: deAuthor.biography.long,
},
};
}
return author;
});
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
console.log(`\n📊 ${updated} Biografien hinzugefügt\n`);
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
writeAuthors(updatedDE, 'de');
writeAuthors(updatedEN, 'en');
console.log('\n✨ Fertig!\n');
console.log('\n✨ Fertig!\n');
}
main();

View file

@ -4,94 +4,107 @@ import fs from 'fs/promises';
import path from 'path';
async function analyzeQuoteCounts() {
console.log('📊 Analyzing quote counts per author...\n');
console.log('📊 Analyzing quote counts per author...\n');
// Load current data
const quotesPath = path.join(process.cwd(), 'services/data/quotes/en.ts');
const authorsPath = path.join(process.cwd(), 'services/data/authors/en.ts');
const quotesContent = await fs.readFile(quotesPath, 'utf-8');
const authorsContent = await fs.readFile(authorsPath, 'utf-8');
// Extract quotes data
const quotesMatch = quotesContent.match(/export const quotesEN[^=]+=\s*(\[[\s\S]*\]);/);
const quotesData = eval(quotesMatch![1]);
// Extract authors data
const authorsMatch = authorsContent.match(/export const authorsEN[^=]+=\s*(\[[\s\S]*\]);/);
const authorsData = eval(authorsMatch![1]);
// Count quotes per author
const quoteCounts = new Map<string, number>();
quotesData.forEach((quote: any) => {
const count = quoteCounts.get(quote.authorId) || 0;
quoteCounts.set(quote.authorId, count + 1);
});
// Create author statistics
const authorStats = authorsData.map((author: any) => ({
id: author.id,
name: author.name,
quoteCount: quoteCounts.get(author.id) || 0,
featured: author.featured || false
}));
// Sort by quote count
authorStats.sort((a: any, b: any) => a.quoteCount - b.quoteCount);
// Show statistics
console.log('📉 Authors with fewest quotes:');
console.log('================================');
const authorsWithNoQuotes = authorStats.filter((a: any) => a.quoteCount === 0);
const authorsWithFewQuotes = authorStats.filter((a: any) => a.quoteCount > 0 && a.quoteCount <= 3);
const authorsWithMediumQuotes = authorStats.filter((a: any) => a.quoteCount > 3 && a.quoteCount <= 10);
console.log(`\n❌ No quotes (${authorsWithNoQuotes.length} authors):`);
authorsWithNoQuotes.forEach((a: any) => {
console.log(`${a.name} (${a.id})`);
});
console.log(`\n⚠ Few quotes (1-3) (${authorsWithFewQuotes.length} authors):`);
authorsWithFewQuotes.slice(0, 20).forEach((a: any) => {
console.log(`${a.name}: ${a.quoteCount} quote${a.quoteCount > 1 ? 's' : ''}`);
});
console.log(`\n📊 Summary:`);
console.log(` • Total authors: ${authorStats.length}`);
console.log(` • Authors with no quotes: ${authorsWithNoQuotes.length}`);
console.log(` • Authors with 1-3 quotes: ${authorsWithFewQuotes.length}`);
console.log(` • Authors with 4-10 quotes: ${authorsWithMediumQuotes.length}`);
console.log(` • Authors with 10+ quotes: ${authorStats.filter((a: any) => a.quoteCount > 10).length}`);
// Top authors
console.log(`\n🏆 Top 5 authors by quote count:`);
authorStats.slice(-5).reverse().forEach((a: any) => {
console.log(`${a.name}: ${a.quoteCount} quotes`);
});
// Save report
const report = {
timestamp: new Date().toISOString(),
totalAuthors: authorStats.length,
totalQuotes: quotesData.length,
authorsWithNoQuotes: authorsWithNoQuotes.map((a: any) => ({ id: a.id, name: a.name })),
authorsWithFewQuotes: authorsWithFewQuotes.map((a: any) => ({ id: a.id, name: a.name, count: a.quoteCount })),
needsMoreQuotes: [...authorsWithNoQuotes, ...authorsWithFewQuotes].map((a: any) => a.id)
};
await fs.writeFile(
path.join(process.cwd(), 'quote-analysis.json'),
JSON.stringify(report, null, 2),
'utf-8'
);
console.log('\n💾 Analysis saved to quote-analysis.json');
return report;
// Load current data
const quotesPath = path.join(process.cwd(), 'services/data/quotes/en.ts');
const authorsPath = path.join(process.cwd(), 'services/data/authors/en.ts');
const quotesContent = await fs.readFile(quotesPath, 'utf-8');
const authorsContent = await fs.readFile(authorsPath, 'utf-8');
// Extract quotes data
const quotesMatch = quotesContent.match(/export const quotesEN[^=]+=\s*(\[[\s\S]*\]);/);
const quotesData = eval(quotesMatch![1]);
// Extract authors data
const authorsMatch = authorsContent.match(/export const authorsEN[^=]+=\s*(\[[\s\S]*\]);/);
const authorsData = eval(authorsMatch![1]);
// Count quotes per author
const quoteCounts = new Map<string, number>();
quotesData.forEach((quote: any) => {
const count = quoteCounts.get(quote.authorId) || 0;
quoteCounts.set(quote.authorId, count + 1);
});
// Create author statistics
const authorStats = authorsData.map((author: any) => ({
id: author.id,
name: author.name,
quoteCount: quoteCounts.get(author.id) || 0,
featured: author.featured || false,
}));
// Sort by quote count
authorStats.sort((a: any, b: any) => a.quoteCount - b.quoteCount);
// Show statistics
console.log('📉 Authors with fewest quotes:');
console.log('================================');
const authorsWithNoQuotes = authorStats.filter((a: any) => a.quoteCount === 0);
const authorsWithFewQuotes = authorStats.filter(
(a: any) => a.quoteCount > 0 && a.quoteCount <= 3
);
const authorsWithMediumQuotes = authorStats.filter(
(a: any) => a.quoteCount > 3 && a.quoteCount <= 10
);
console.log(`\n❌ No quotes (${authorsWithNoQuotes.length} authors):`);
authorsWithNoQuotes.forEach((a: any) => {
console.log(`${a.name} (${a.id})`);
});
console.log(`\n⚠ Few quotes (1-3) (${authorsWithFewQuotes.length} authors):`);
authorsWithFewQuotes.slice(0, 20).forEach((a: any) => {
console.log(`${a.name}: ${a.quoteCount} quote${a.quoteCount > 1 ? 's' : ''}`);
});
console.log(`\n📊 Summary:`);
console.log(` • Total authors: ${authorStats.length}`);
console.log(` • Authors with no quotes: ${authorsWithNoQuotes.length}`);
console.log(` • Authors with 1-3 quotes: ${authorsWithFewQuotes.length}`);
console.log(` • Authors with 4-10 quotes: ${authorsWithMediumQuotes.length}`);
console.log(
` • Authors with 10+ quotes: ${authorStats.filter((a: any) => a.quoteCount > 10).length}`
);
// Top authors
console.log(`\n🏆 Top 5 authors by quote count:`);
authorStats
.slice(-5)
.reverse()
.forEach((a: any) => {
console.log(`${a.name}: ${a.quoteCount} quotes`);
});
// Save report
const report = {
timestamp: new Date().toISOString(),
totalAuthors: authorStats.length,
totalQuotes: quotesData.length,
authorsWithNoQuotes: authorsWithNoQuotes.map((a: any) => ({ id: a.id, name: a.name })),
authorsWithFewQuotes: authorsWithFewQuotes.map((a: any) => ({
id: a.id,
name: a.name,
count: a.quoteCount,
})),
needsMoreQuotes: [...authorsWithNoQuotes, ...authorsWithFewQuotes].map((a: any) => a.id),
};
await fs.writeFile(
path.join(process.cwd(), 'quote-analysis.json'),
JSON.stringify(report, null, 2),
'utf-8'
);
console.log('\n💾 Analysis saved to quote-analysis.json');
return report;
}
analyzeQuoteCounts().catch(error => {
console.error('❌ Script failed:', error);
process.exit(1);
});
analyzeQuoteCounts().catch((error) => {
console.error('❌ Script failed:', error);
process.exit(1);
});

View file

@ -9,255 +9,274 @@ const path = require('path');
// Find the highest quote ID to continue numbering
function getNextQuoteId(quotes) {
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return maxId + 1;
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return maxId + 1;
}
// Buddha quotes (currently has only 1!)
const buddhaQuotes = [
{
german: "Das Glück liegt nicht im Besitz, sondern im Genießen.",
english: "Happiness does not depend on what you have or who you are, it solely relies on what you think.",
categories: ["happiness", "mindfulness", "wisdom"],
tags: ["happiness", "possession", "mindfulness", "contentment"],
featured: true
},
{
german: "Hass wird niemals durch Hass beendet. Hass wird durch Liebe beendet. Das ist das ewige Gesetz.",
english: "Hatred is never appeased by hatred in this world. By non-hatred alone is hatred appeased. This is a law eternal.",
categories: ["love", "peace", "wisdom"],
tags: ["hate", "love", "peace", "eternal-law"],
featured: true
},
{
german: "Der Geist ist alles. Was du denkst, das wirst du.",
english: "The mind is everything. What you think you become.",
categories: ["mind", "thoughts", "self-development"],
tags: ["mind", "thoughts", "becoming", "consciousness"],
featured: true
},
{
german: "Tausende von Kerzen können von einer einzigen Kerze angezündet werden, ohne dass ihr Licht schwächer wird.",
english: "Thousands of candles can be lighted from a single candle, and the life of the candle will not be shortened.",
categories: ["sharing", "wisdom", "generosity"],
tags: ["sharing", "light", "generosity", "abundance"]
},
{
german: "Verweile nicht in der Vergangenheit, träume nicht von der Zukunft. Konzentriere dich auf den gegenwärtigen Moment.",
english: "Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.",
categories: ["mindfulness", "present", "meditation"],
tags: ["present", "mindfulness", "past", "future"],
featured: true
},
{
german: "Drei Dinge können nicht lange verborgen bleiben: die Sonne, der Mond und die Wahrheit.",
english: "Three things cannot be long hidden: the sun, the moon, and the truth.",
categories: ["truth", "wisdom", "nature"],
tags: ["truth", "sun", "moon", "hidden"]
},
{
german: "Frieden kommt von innen. Suche ihn nicht im Äußeren.",
english: "Peace comes from within. Do not seek it without.",
categories: ["peace", "inner-peace", "wisdom"],
tags: ["peace", "inner", "seeking", "external"]
},
{
german: "Du selbst musst dich um dein eigenes Wohlbefinden kümmern. Niemand sonst kann das für dich tun.",
english: "No one saves us but ourselves. No one can and no one may. We ourselves must walk the path.",
categories: ["self-reliance", "responsibility", "independence"],
tags: ["self-reliance", "responsibility", "independence", "path"]
},
{
german: "Gesundheit ist die größte Gabe, Zufriedenheit der größte Reichtum.",
english: "Health is the greatest gift, contentment the greatest wealth, faithfulness the best relationship.",
categories: ["health", "contentment", "wisdom"],
tags: ["health", "contentment", "wealth", "gifts"]
}
{
german: 'Das Glück liegt nicht im Besitz, sondern im Genießen.',
english:
'Happiness does not depend on what you have or who you are, it solely relies on what you think.',
categories: ['happiness', 'mindfulness', 'wisdom'],
tags: ['happiness', 'possession', 'mindfulness', 'contentment'],
featured: true,
},
{
german:
'Hass wird niemals durch Hass beendet. Hass wird durch Liebe beendet. Das ist das ewige Gesetz.',
english:
'Hatred is never appeased by hatred in this world. By non-hatred alone is hatred appeased. This is a law eternal.',
categories: ['love', 'peace', 'wisdom'],
tags: ['hate', 'love', 'peace', 'eternal-law'],
featured: true,
},
{
german: 'Der Geist ist alles. Was du denkst, das wirst du.',
english: 'The mind is everything. What you think you become.',
categories: ['mind', 'thoughts', 'self-development'],
tags: ['mind', 'thoughts', 'becoming', 'consciousness'],
featured: true,
},
{
german:
'Tausende von Kerzen können von einer einzigen Kerze angezündet werden, ohne dass ihr Licht schwächer wird.',
english:
'Thousands of candles can be lighted from a single candle, and the life of the candle will not be shortened.',
categories: ['sharing', 'wisdom', 'generosity'],
tags: ['sharing', 'light', 'generosity', 'abundance'],
},
{
german:
'Verweile nicht in der Vergangenheit, träume nicht von der Zukunft. Konzentriere dich auf den gegenwärtigen Moment.',
english:
'Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.',
categories: ['mindfulness', 'present', 'meditation'],
tags: ['present', 'mindfulness', 'past', 'future'],
featured: true,
},
{
german:
'Drei Dinge können nicht lange verborgen bleiben: die Sonne, der Mond und die Wahrheit.',
english: 'Three things cannot be long hidden: the sun, the moon, and the truth.',
categories: ['truth', 'wisdom', 'nature'],
tags: ['truth', 'sun', 'moon', 'hidden'],
},
{
german: 'Frieden kommt von innen. Suche ihn nicht im Äußeren.',
english: 'Peace comes from within. Do not seek it without.',
categories: ['peace', 'inner-peace', 'wisdom'],
tags: ['peace', 'inner', 'seeking', 'external'],
},
{
german:
'Du selbst musst dich um dein eigenes Wohlbefinden kümmern. Niemand sonst kann das für dich tun.',
english:
'No one saves us but ourselves. No one can and no one may. We ourselves must walk the path.',
categories: ['self-reliance', 'responsibility', 'independence'],
tags: ['self-reliance', 'responsibility', 'independence', 'path'],
},
{
german: 'Gesundheit ist die größte Gabe, Zufriedenheit der größte Reichtum.',
english:
'Health is the greatest gift, contentment the greatest wealth, faithfulness the best relationship.',
categories: ['health', 'contentment', 'wisdom'],
tags: ['health', 'contentment', 'wealth', 'gifts'],
},
];
// Nietzsche quotes (currently has 5, let's add more)
const nietzscheQuotes = [
{
german: "Gott ist tot! Gott bleibt tot! Und wir haben ihn getötet!",
english: "God is dead! God remains dead! And we have killed him!",
source: "Die fröhliche Wissenschaft",
year: 1882,
categories: ["philosophy", "religion", "nihilism"],
tags: ["god", "death", "nihilism", "religion"],
featured: true
},
{
german: "Was aus Liebe getan wird, geschieht immer jenseits von Gut und Böse.",
english: "What is done out of love always takes place beyond good and evil.",
categories: ["love", "morality", "philosophy"],
tags: ["love", "morality", "good", "evil"],
featured: true
},
{
german: "Der Mensch ist etwas, das überwunden werden soll.",
english: "Man is something that shall be overcome.",
source: "Also sprach Zarathustra",
year: 1883,
categories: ["philosophy", "human-nature", "evolution"],
tags: ["human", "overcome", "evolution", "development"]
},
{
german: "Ohne Musik wäre das Leben ein Irrtum.",
english: "Without music, life would be a mistake.",
categories: ["music", "art", "life"],
tags: ["music", "life", "art", "mistake"],
featured: true
},
{
german: "Werde, was du bist.",
english: "Become who you are.",
categories: ["self-development", "identity", "authenticity"],
tags: ["become", "identity", "authenticity", "self"],
featured: true
},
{
german: "Wer ein Warum zu leben hat, erträgt fast jedes Wie.",
english: "He who has a why to live can bear almost any how.",
categories: ["purpose", "meaning", "resilience"],
tags: ["why", "purpose", "meaning", "endurance"],
featured: true
},
{
german: "Die Hoffnung ist der schlechteste der Übel, denn sie verlängert die Qualen des Menschen.",
english: "Hope is the worst of evils, for it prolongs the torments of man.",
categories: ["hope", "philosophy", "suffering"],
tags: ["hope", "evil", "suffering", "torment"]
},
{
german: "Alle Vorurteile stammen aus den Eingeweiden.",
english: "All prejudices come from the intestines.",
categories: ["prejudice", "wisdom", "psychology"],
tags: ["prejudice", "intuition", "psychology", "bias"]
},
{
german: "Das Individuum hat immer gegen die Gesellschaft zu kämpfen.",
english: "The individual has always had to struggle not to be overwhelmed by the tribe.",
categories: ["individualism", "society", "independence"],
tags: ["individual", "society", "struggle", "independence"]
}
{
german: 'Gott ist tot! Gott bleibt tot! Und wir haben ihn getötet!',
english: 'God is dead! God remains dead! And we have killed him!',
source: 'Die fröhliche Wissenschaft',
year: 1882,
categories: ['philosophy', 'religion', 'nihilism'],
tags: ['god', 'death', 'nihilism', 'religion'],
featured: true,
},
{
german: 'Was aus Liebe getan wird, geschieht immer jenseits von Gut und Böse.',
english: 'What is done out of love always takes place beyond good and evil.',
categories: ['love', 'morality', 'philosophy'],
tags: ['love', 'morality', 'good', 'evil'],
featured: true,
},
{
german: 'Der Mensch ist etwas, das überwunden werden soll.',
english: 'Man is something that shall be overcome.',
source: 'Also sprach Zarathustra',
year: 1883,
categories: ['philosophy', 'human-nature', 'evolution'],
tags: ['human', 'overcome', 'evolution', 'development'],
},
{
german: 'Ohne Musik wäre das Leben ein Irrtum.',
english: 'Without music, life would be a mistake.',
categories: ['music', 'art', 'life'],
tags: ['music', 'life', 'art', 'mistake'],
featured: true,
},
{
german: 'Werde, was du bist.',
english: 'Become who you are.',
categories: ['self-development', 'identity', 'authenticity'],
tags: ['become', 'identity', 'authenticity', 'self'],
featured: true,
},
{
german: 'Wer ein Warum zu leben hat, erträgt fast jedes Wie.',
english: 'He who has a why to live can bear almost any how.',
categories: ['purpose', 'meaning', 'resilience'],
tags: ['why', 'purpose', 'meaning', 'endurance'],
featured: true,
},
{
german:
'Die Hoffnung ist der schlechteste der Übel, denn sie verlängert die Qualen des Menschen.',
english: 'Hope is the worst of evils, for it prolongs the torments of man.',
categories: ['hope', 'philosophy', 'suffering'],
tags: ['hope', 'evil', 'suffering', 'torment'],
},
{
german: 'Alle Vorurteile stammen aus den Eingeweiden.',
english: 'All prejudices come from the intestines.',
categories: ['prejudice', 'wisdom', 'psychology'],
tags: ['prejudice', 'intuition', 'psychology', 'bias'],
},
{
german: 'Das Individuum hat immer gegen die Gesellschaft zu kämpfen.',
english: 'The individual has always had to struggle not to be overwhelmed by the tribe.',
categories: ['individualism', 'society', 'independence'],
tags: ['individual', 'society', 'struggle', 'independence'],
},
];
async function addMoreQuotes() {
console.log('🧘‍♂️ Adding Buddha quotes (currently 1) and more Nietzsche quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`Current quotes: ${deQuotes.quotes.length}`);
let nextIdNum = getNextQuoteId(deQuotes.quotes);
// Add Buddha quotes
const buddhaAuthorId = 'buddha'; // From analysis
console.log(`Adding ${buddhaQuotes.length} Buddha quotes...`);
buddhaQuotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: buddhaAuthorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: buddhaAuthorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500
};
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
nextIdNum += buddhaQuotes.length;
// Add Nietzsche quotes
const nietzscheAuthorId = 'nietzsche-friedrich';
console.log(`Adding ${nietzscheQuotes.length} more Nietzsche quotes...`);
nietzscheQuotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: nietzscheAuthorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3500) + 1200
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: nietzscheAuthorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source: quote.source === "Die fröhliche Wissenschaft" ? "The Gay Science" :
quote.source === "Also sprach Zarathustra" ? "Thus Spoke Zarathustra" : quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3500) + 1200
};
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
// Save files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
const totalAdded = buddhaQuotes.length + nietzscheQuotes.length;
console.log(`✅ Added ${totalAdded} quotes total:`);
console.log(` • Buddha: ${buddhaQuotes.length} quotes (was 1, now ${1 + buddhaQuotes.length})`);
console.log(` • Nietzsche: ${nietzscheQuotes.length} quotes (was 5, now ${5 + nietzscheQuotes.length})`);
console.log(`📊 New total: ${deQuotes.quotes.length} quotes in both languages`);
} catch (error) {
console.error('❌ Failed to add quotes:', error);
}
console.log('🧘‍♂️ Adding Buddha quotes (currently 1) and more Nietzsche quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`Current quotes: ${deQuotes.quotes.length}`);
let nextIdNum = getNextQuoteId(deQuotes.quotes);
// Add Buddha quotes
const buddhaAuthorId = 'buddha'; // From analysis
console.log(`Adding ${buddhaQuotes.length} Buddha quotes...`);
buddhaQuotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: buddhaAuthorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500,
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: buddhaAuthorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500,
};
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
nextIdNum += buddhaQuotes.length;
// Add Nietzsche quotes
const nietzscheAuthorId = 'nietzsche-friedrich';
console.log(`Adding ${nietzscheQuotes.length} more Nietzsche quotes...`);
nietzscheQuotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: nietzscheAuthorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3500) + 1200,
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: nietzscheAuthorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source:
quote.source === 'Die fröhliche Wissenschaft'
? 'The Gay Science'
: quote.source === 'Also sprach Zarathustra'
? 'Thus Spoke Zarathustra'
: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3500) + 1200,
};
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
// Save files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
const totalAdded = buddhaQuotes.length + nietzscheQuotes.length;
console.log(`✅ Added ${totalAdded} quotes total:`);
console.log(
` • Buddha: ${buddhaQuotes.length} quotes (was 1, now ${1 + buddhaQuotes.length})`
);
console.log(
` • Nietzsche: ${nietzscheQuotes.length} quotes (was 5, now ${5 + nietzscheQuotes.length})`
);
console.log(`📊 New total: ${deQuotes.quotes.length} quotes in both languages`);
} catch (error) {
console.error('❌ Failed to add quotes:', error);
}
}
if (require.main === module) {
addMoreQuotes();
addMoreQuotes();
}
module.exports = { addMoreQuotes };
module.exports = { addMoreQuotes };

View file

@ -8,274 +8,304 @@ const fs = require('fs');
const path = require('path');
function getNextQuoteId(quotes) {
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return maxId + 1;
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return maxId + 1;
}
// Platon quotes (currently has only 1!)
const platonQuotes = [
{
german: "Der ungeprüfte Lebensgang ist des Menschen nicht wert.",
english: "The unexamined life is not worth living.",
categories: ["philosophy", "self-knowledge", "wisdom"],
tags: ["examination", "life", "wisdom", "knowledge"],
featured: true
},
{
german: "Die Wahrheit ist die Tochter der Zeit.",
english: "Truth is the daughter of time.",
categories: ["truth", "time", "philosophy"],
tags: ["truth", "time", "revelation", "patience"]
},
{
german: "Wissen ist die einzige Tugend und Unwissen das einzige Laster.",
english: "Knowledge is the only virtue and ignorance the only vice.",
categories: ["knowledge", "virtue", "education"],
tags: ["knowledge", "virtue", "ignorance", "vice"],
featured: true
},
{
german: "Die Schönheit liegt im Auge des Betrachters.",
english: "Beauty lies in the eyes of the beholder.",
categories: ["beauty", "perception", "philosophy"],
tags: ["beauty", "perception", "subjectivity", "observation"]
},
{
german: "Gerechtigkeit ist die Tugend der Seele.",
english: "Justice is the virtue of the soul.",
categories: ["justice", "virtue", "soul"],
tags: ["justice", "virtue", "soul", "morality"],
featured: true
},
{
german: "Lernen bedeutet sich zu erinnern.",
english: "Learning is remembrance.",
categories: ["learning", "memory", "knowledge"],
tags: ["learning", "memory", "remembrance", "education"]
},
{
german: "Nur die Toten haben das Ende des Krieges gesehen.",
english: "Only the dead have seen the end of war.",
categories: ["war", "death", "peace"],
tags: ["war", "death", "peace", "mortality"]
}
{
german: 'Der ungeprüfte Lebensgang ist des Menschen nicht wert.',
english: 'The unexamined life is not worth living.',
categories: ['philosophy', 'self-knowledge', 'wisdom'],
tags: ['examination', 'life', 'wisdom', 'knowledge'],
featured: true,
},
{
german: 'Die Wahrheit ist die Tochter der Zeit.',
english: 'Truth is the daughter of time.',
categories: ['truth', 'time', 'philosophy'],
tags: ['truth', 'time', 'revelation', 'patience'],
},
{
german: 'Wissen ist die einzige Tugend und Unwissen das einzige Laster.',
english: 'Knowledge is the only virtue and ignorance the only vice.',
categories: ['knowledge', 'virtue', 'education'],
tags: ['knowledge', 'virtue', 'ignorance', 'vice'],
featured: true,
},
{
german: 'Die Schönheit liegt im Auge des Betrachters.',
english: 'Beauty lies in the eyes of the beholder.',
categories: ['beauty', 'perception', 'philosophy'],
tags: ['beauty', 'perception', 'subjectivity', 'observation'],
},
{
german: 'Gerechtigkeit ist die Tugend der Seele.',
english: 'Justice is the virtue of the soul.',
categories: ['justice', 'virtue', 'soul'],
tags: ['justice', 'virtue', 'soul', 'morality'],
featured: true,
},
{
german: 'Lernen bedeutet sich zu erinnern.',
english: 'Learning is remembrance.',
categories: ['learning', 'memory', 'knowledge'],
tags: ['learning', 'memory', 'remembrance', 'education'],
},
{
german: 'Nur die Toten haben das Ende des Krieges gesehen.',
english: 'Only the dead have seen the end of war.',
categories: ['war', 'death', 'peace'],
tags: ['war', 'death', 'peace', 'mortality'],
},
];
// Saint-Exupéry quotes (currently has 3, let's add more from The Little Prince)
const saintExuperyQuotes = [
{
german: "Die Zeit, die du für deine Rose verloren hast, sie macht deine Rose so wichtig.",
english: "It is the time you have wasted for your rose that makes your rose so important.",
source: "Der kleine Prinz",
year: 1943,
categories: ["love", "time", "relationships"],
tags: ["time", "love", "importance", "relationships"],
featured: true
},
{
german: "Wenn du ein Schiff bauen willst, dann trommle nicht Männer zusammen, sondern lehre sie die Sehnsucht nach dem weiten, endlosen Meer.",
english: "If you want to build a ship, don't drum up people to collect wood and don't assign them tasks, but rather teach them to long for the endless sea.",
categories: ["leadership", "motivation", "vision"],
tags: ["leadership", "vision", "motivation", "inspiration"],
featured: true
},
{
german: "Es ist viel schwerer, über sich selbst zu urteilen, als über andere.",
english: "It is much more difficult to judge oneself than to judge others.",
source: "Der kleine Prinz",
year: 1943,
categories: ["self-knowledge", "judgment", "wisdom"],
tags: ["self-judgment", "wisdom", "introspection", "difficulty"]
},
{
german: "Alle großen Leute waren einmal Kinder, aber nur wenige erinnern sich daran.",
english: "All grown-ups were once children... but only few of them remember it.",
source: "Der kleine Prinz",
year: 1943,
categories: ["childhood", "memory", "wisdom"],
tags: ["childhood", "adults", "memory", "innocence"],
featured: true
},
{
german: "Perfektion ist nicht dann erreicht, wenn es nichts mehr hinzuzufügen gibt, sondern wenn nichts mehr weggenommen werden kann.",
english: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.",
categories: ["perfection", "simplicity", "design"],
tags: ["perfection", "simplicity", "minimalism", "design"],
featured: true
},
{
german: "Liebe besteht nicht darin, dass man einander ansieht, sondern dass man gemeinsam in die gleiche Richtung blickt.",
english: "Love does not consist of gazing at each other, but in looking outward together in the same direction.",
categories: ["love", "relationships", "partnership"],
tags: ["love", "partnership", "direction", "unity"]
}
{
german: 'Die Zeit, die du für deine Rose verloren hast, sie macht deine Rose so wichtig.',
english: 'It is the time you have wasted for your rose that makes your rose so important.',
source: 'Der kleine Prinz',
year: 1943,
categories: ['love', 'time', 'relationships'],
tags: ['time', 'love', 'importance', 'relationships'],
featured: true,
},
{
german:
'Wenn du ein Schiff bauen willst, dann trommle nicht Männer zusammen, sondern lehre sie die Sehnsucht nach dem weiten, endlosen Meer.',
english:
"If you want to build a ship, don't drum up people to collect wood and don't assign them tasks, but rather teach them to long for the endless sea.",
categories: ['leadership', 'motivation', 'vision'],
tags: ['leadership', 'vision', 'motivation', 'inspiration'],
featured: true,
},
{
german: 'Es ist viel schwerer, über sich selbst zu urteilen, als über andere.',
english: 'It is much more difficult to judge oneself than to judge others.',
source: 'Der kleine Prinz',
year: 1943,
categories: ['self-knowledge', 'judgment', 'wisdom'],
tags: ['self-judgment', 'wisdom', 'introspection', 'difficulty'],
},
{
german: 'Alle großen Leute waren einmal Kinder, aber nur wenige erinnern sich daran.',
english: 'All grown-ups were once children... but only few of them remember it.',
source: 'Der kleine Prinz',
year: 1943,
categories: ['childhood', 'memory', 'wisdom'],
tags: ['childhood', 'adults', 'memory', 'innocence'],
featured: true,
},
{
german:
'Perfektion ist nicht dann erreicht, wenn es nichts mehr hinzuzufügen gibt, sondern wenn nichts mehr weggenommen werden kann.',
english:
'Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.',
categories: ['perfection', 'simplicity', 'design'],
tags: ['perfection', 'simplicity', 'minimalism', 'design'],
featured: true,
},
{
german:
'Liebe besteht nicht darin, dass man einander ansieht, sondern dass man gemeinsam in die gleiche Richtung blickt.',
english:
'Love does not consist of gazing at each other, but in looking outward together in the same direction.',
categories: ['love', 'relationships', 'partnership'],
tags: ['love', 'partnership', 'direction', 'unity'],
},
];
// Sokrates quotes (currently has 3, let's add more)
const sokratesQuotes = [
{
german: "Ich weiß, dass ich nichts weiß.",
english: "I know that I know nothing.",
categories: ["wisdom", "humility", "knowledge"],
tags: ["knowledge", "humility", "wisdom", "ignorance"],
featured: true
},
{
german: "Ein Leben ohne Prüfung ist nicht lebenswert.",
english: "The unexamined life is not worth living.",
categories: ["philosophy", "self-reflection", "wisdom"],
tags: ["examination", "life", "reflection", "worth"]
},
{
german: "Tugend ist Wissen.",
english: "Virtue is knowledge.",
categories: ["virtue", "knowledge", "morality"],
tags: ["virtue", "knowledge", "morality", "ethics"]
},
{
german: "Niemand tut freiwillig Böses.",
english: "No one does wrong willingly.",
categories: ["morality", "evil", "philosophy"],
tags: ["evil", "will", "morality", "intention"]
},
{
german: "Das einzige Gute ist Wissen und das einzige Schlechte ist Unwissenheit.",
english: "The only good is knowledge and the only evil is ignorance.",
categories: ["knowledge", "good", "evil"],
tags: ["knowledge", "good", "evil", "ignorance"],
featured: true
}
{
german: 'Ich weiß, dass ich nichts weiß.',
english: 'I know that I know nothing.',
categories: ['wisdom', 'humility', 'knowledge'],
tags: ['knowledge', 'humility', 'wisdom', 'ignorance'],
featured: true,
},
{
german: 'Ein Leben ohne Prüfung ist nicht lebenswert.',
english: 'The unexamined life is not worth living.',
categories: ['philosophy', 'self-reflection', 'wisdom'],
tags: ['examination', 'life', 'reflection', 'worth'],
},
{
german: 'Tugend ist Wissen.',
english: 'Virtue is knowledge.',
categories: ['virtue', 'knowledge', 'morality'],
tags: ['virtue', 'knowledge', 'morality', 'ethics'],
},
{
german: 'Niemand tut freiwillig Böses.',
english: 'No one does wrong willingly.',
categories: ['morality', 'evil', 'philosophy'],
tags: ['evil', 'will', 'morality', 'intention'],
},
{
german: 'Das einzige Gute ist Wissen und das einzige Schlechte ist Unwissenheit.',
english: 'The only good is knowledge and the only evil is ignorance.',
categories: ['knowledge', 'good', 'evil'],
tags: ['knowledge', 'good', 'evil', 'ignorance'],
featured: true,
},
];
// Marcus Aurelius quotes (currently has 3, let's add more)
const marcusAureliusQuotes = [
{
german: "Du hast Macht über deinen Geist - nicht über äußere Ereignisse. Erkenne das, und du wirst Stärke finden.",
english: "You have power over your mind - not outside events. Realize this, and you will find strength.",
source: "Selbstbetrachtungen",
categories: ["stoicism", "mind", "control"],
tags: ["mind", "control", "strength", "events"],
featured: true
},
{
german: "Sehr wenig ist nötig, um ein glückliches Leben zu führen; alles liegt in dir selbst, in deiner Denkweise.",
english: "Very little is needed to make a happy life; it is all within yourself, in your way of thinking.",
source: "Selbstbetrachtungen",
categories: ["happiness", "stoicism", "thinking"],
tags: ["happiness", "thinking", "simplicity", "inner-life"],
featured: true
},
{
german: "Verschwende keine Zeit mehr damit zu diskutieren, was ein guter Mensch sein sollte. Sei einer.",
english: "Waste no more time arguing what a good man should be. Be one.",
source: "Selbstbetrachtungen",
categories: ["action", "virtue", "character"],
tags: ["action", "virtue", "character", "goodness"],
featured: true
},
{
german: "Das Glück deines Lebens hängt von der Beschaffenheit deiner Gedanken ab.",
english: "The happiness of your life depends upon the quality of your thoughts.",
source: "Selbstbetrachtungen",
categories: ["happiness", "thoughts", "stoicism"],
tags: ["happiness", "thoughts", "quality", "life"]
},
{
german: "Akzeptiere die Dinge, an die das Schicksal dich bindet, und liebe die Menschen, mit denen das Schicksal dich zusammenführt.",
english: "Accept the things to which fate binds you, and love the people with whom fate brings you together.",
source: "Selbstbetrachtungen",
categories: ["fate", "acceptance", "love"],
tags: ["fate", "acceptance", "love", "people"]
}
{
german:
'Du hast Macht über deinen Geist - nicht über äußere Ereignisse. Erkenne das, und du wirst Stärke finden.',
english:
'You have power over your mind - not outside events. Realize this, and you will find strength.',
source: 'Selbstbetrachtungen',
categories: ['stoicism', 'mind', 'control'],
tags: ['mind', 'control', 'strength', 'events'],
featured: true,
},
{
german:
'Sehr wenig ist nötig, um ein glückliches Leben zu führen; alles liegt in dir selbst, in deiner Denkweise.',
english:
'Very little is needed to make a happy life; it is all within yourself, in your way of thinking.',
source: 'Selbstbetrachtungen',
categories: ['happiness', 'stoicism', 'thinking'],
tags: ['happiness', 'thinking', 'simplicity', 'inner-life'],
featured: true,
},
{
german:
'Verschwende keine Zeit mehr damit zu diskutieren, was ein guter Mensch sein sollte. Sei einer.',
english: 'Waste no more time arguing what a good man should be. Be one.',
source: 'Selbstbetrachtungen',
categories: ['action', 'virtue', 'character'],
tags: ['action', 'virtue', 'character', 'goodness'],
featured: true,
},
{
german: 'Das Glück deines Lebens hängt von der Beschaffenheit deiner Gedanken ab.',
english: 'The happiness of your life depends upon the quality of your thoughts.',
source: 'Selbstbetrachtungen',
categories: ['happiness', 'thoughts', 'stoicism'],
tags: ['happiness', 'thoughts', 'quality', 'life'],
},
{
german:
'Akzeptiere die Dinge, an die das Schicksal dich bindet, und liebe die Menschen, mit denen das Schicksal dich zusammenführt.',
english:
'Accept the things to which fate binds you, and love the people with whom fate brings you together.',
source: 'Selbstbetrachtungen',
categories: ['fate', 'acceptance', 'love'],
tags: ['fate', 'acceptance', 'love', 'people'],
},
];
async function addMajorAuthorsQuotes() {
console.log('📚 Adding quotes for major authors who need more content...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`Current quotes: ${deQuotes.quotes.length}`);
let nextIdNum = getNextQuoteId(deQuotes.quotes);
const authorsToAdd = [
{ name: 'Platon', id: 'platon', quotes: platonQuotes, currentCount: 1 },
{ name: 'Saint-Exupéry', id: 'saint-exupery-antoine', quotes: saintExuperyQuotes, currentCount: 3 },
{ name: 'Sokrates', id: 'sokrates', quotes: sokratesQuotes, currentCount: 3 },
{ name: 'Marcus Aurelius', id: 'marcus-aurelius', quotes: marcusAureliusQuotes, currentCount: 3 }
];
let totalAdded = 0;
for (const author of authorsToAdd) {
console.log(`Adding ${author.quotes.length} quotes for ${author.name} (was ${author.currentCount})...`);
author.quotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: author.id,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: author.id,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source: quote.source === "Der kleine Prinz" ? "The Little Prince" :
quote.source === "Selbstbetrachtungen" ? "Meditations" : quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000
};
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
nextIdNum += author.quotes.length;
totalAdded += author.quotes.length;
}
// Save files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Added ${totalAdded} quotes total:`);
authorsToAdd.forEach(author => {
console.log(`${author.name}: +${author.quotes.length} quotes (was ${author.currentCount}, now ${author.currentCount + author.quotes.length})`);
});
console.log(`📊 New total: ${deQuotes.quotes.length} quotes in both languages`);
} catch (error) {
console.error('❌ Failed to add quotes:', error);
}
console.log('📚 Adding quotes for major authors who need more content...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`Current quotes: ${deQuotes.quotes.length}`);
let nextIdNum = getNextQuoteId(deQuotes.quotes);
const authorsToAdd = [
{ name: 'Platon', id: 'platon', quotes: platonQuotes, currentCount: 1 },
{
name: 'Saint-Exupéry',
id: 'saint-exupery-antoine',
quotes: saintExuperyQuotes,
currentCount: 3,
},
{ name: 'Sokrates', id: 'sokrates', quotes: sokratesQuotes, currentCount: 3 },
{
name: 'Marcus Aurelius',
id: 'marcus-aurelius',
quotes: marcusAureliusQuotes,
currentCount: 3,
},
];
let totalAdded = 0;
for (const author of authorsToAdd) {
console.log(
`Adding ${author.quotes.length} quotes for ${author.name} (was ${author.currentCount})...`
);
author.quotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: author.id,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000,
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: author.id,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source:
quote.source === 'Der kleine Prinz'
? 'The Little Prince'
: quote.source === 'Selbstbetrachtungen'
? 'Meditations'
: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000,
};
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
nextIdNum += author.quotes.length;
totalAdded += author.quotes.length;
}
// Save files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Added ${totalAdded} quotes total:`);
authorsToAdd.forEach((author) => {
console.log(
`${author.name}: +${author.quotes.length} quotes (was ${author.currentCount}, now ${author.currentCount + author.quotes.length})`
);
});
console.log(`📊 New total: ${deQuotes.quotes.length} quotes in both languages`);
} catch (error) {
console.error('❌ Failed to add quotes:', error);
}
}
if (require.main === module) {
addMajorAuthorsQuotes();
addMajorAuthorsQuotes();
}
module.exports = { addMajorAuthorsQuotes };
module.exports = { addMajorAuthorsQuotes };

View file

@ -8,274 +8,295 @@ const fs = require('fs');
const path = require('path');
function getNextQuoteId(quotes) {
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return maxId + 1;
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return maxId + 1;
}
// Additional Einstein quotes - his most famous and profound quotes
const einsteinQuotes = [
{
german: "Die wichtigste Entscheidung, die du triffst, ist, ob du in einem freundlichen oder feindlichen Universum lebst.",
english: "The most important decision we make is whether we believe we live in a friendly or hostile universe.",
categories: ["philosophy", "mindset", "universe"],
tags: ["universe", "belief", "decision", "perspective"],
featured: true
},
{
german: "Wissenschaft ohne Religion ist lahm, Religion ohne Wissenschaft ist blind.",
english: "Science without religion is lame, religion without science is blind.",
categories: ["science", "religion", "philosophy"],
tags: ["science", "religion", "balance", "wisdom"],
year: 1941,
source: "Science and Religion"
},
{
german: "Ich habe keine besondere Begabung, sondern bin nur leidenschaftlich neugierig.",
english: "I have no special talent. I am only passionately curious.",
categories: ["humility", "curiosity", "learning"],
tags: ["curiosity", "talent", "humility", "passion"],
featured: true
},
{
german: "Das Leben ist wie Fahrrad fahren. Um die Balance zu halten, musst du in Bewegung bleiben.",
english: "Life is like riding a bicycle. To keep your balance, you must keep moving.",
categories: ["life", "balance", "movement"],
tags: ["life", "balance", "movement", "progress"],
year: 1930,
source: "Brief an seinen Sohn Eduard",
featured: true
},
{
german: "Logik bringt dich von A nach B. Phantasie bringt dich überall hin.",
english: "Logic will get you from A to B. Imagination will take you everywhere.",
categories: ["creativity", "logic", "wisdom"],
tags: ["logic", "imagination", "creativity", "thinking"]
},
{
german: "Wenn du es nicht einfach erklären kannst, verstehst du es nicht gut genug.",
english: "If you can't explain it simply, you don't understand it well enough.",
categories: ["knowledge", "teaching", "understanding"],
tags: ["simplicity", "understanding", "explanation", "knowledge"],
featured: true
},
{
german: "Der Unterschied zwischen Genialität und Dummheit ist, dass Genialität ihre Grenzen hat.",
english: "The difference between genius and stupidity is that genius has its limits.",
categories: ["humor", "intelligence", "wisdom"],
tags: ["genius", "stupidity", "limits", "humor"]
},
{
german: "Es gibt zwei Arten sein Leben zu leben: entweder so, als wäre nichts ein Wunder, oder so, als wäre alles ein Wunder.",
english: "There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.",
categories: ["life", "wonder", "perspective"],
tags: ["miracle", "life", "wonder", "perspective"],
featured: true
},
{
german: "Die Welt wird nicht bedroht von den Menschen, die böse sind, sondern von denen, die das Böse zulassen.",
english: "The world will not be destroyed by those who do evil, but by those who watch them without doing anything.",
categories: ["evil", "responsibility", "action"],
tags: ["evil", "inaction", "responsibility", "world"],
featured: true
},
{
german: "Ich denke niemals an die Zukunft. Sie kommt früh genug.",
english: "I never think of the future. It comes soon enough.",
categories: ["time", "future", "present"],
tags: ["future", "time", "present", "worry"]
},
{
german: "Wer es in kleinen Dingen mit der Wahrheit nicht ernst nimmt, dem kann man auch in großen Dingen nicht vertrauen.",
english: "Whoever is careless with the truth in small matters cannot be trusted with important matters.",
categories: ["truth", "trust", "integrity"],
tags: ["truth", "trust", "integrity", "honesty"],
featured: true
},
{
german: "Die einzige Quelle des Wissens ist die Erfahrung.",
english: "The only source of knowledge is experience.",
categories: ["knowledge", "experience", "learning"],
tags: ["knowledge", "experience", "learning", "wisdom"]
},
{
german: "Versuche nicht, ein erfolgreicher, sondern ein wertvoller Mensch zu werden.",
english: "Try not to become a person of success, but rather try to become a person of value.",
categories: ["success", "values", "character"],
tags: ["success", "value", "character", "purpose"],
featured: true
},
{
german: "Das Schönste, was wir erleben können, ist das Geheimnisvolle.",
english: "The most beautiful thing we can experience is the mysterious.",
categories: ["mystery", "beauty", "wonder"],
tags: ["mystery", "beauty", "experience", "wonder"],
source: "Mein Weltbild",
year: 1931
},
{
german: "In der Mitte von Schwierigkeiten liegen Möglichkeiten.",
english: "In the middle of difficulty lies opportunity.",
categories: ["opportunity", "challenges", "optimism"],
tags: ["difficulty", "opportunity", "challenges", "optimism"],
featured: true
},
{
german: "Wenige sind imstande, von den Vorurteilen der Umgebung abweichende Meinungen gelassen auszusprechen.",
english: "Few are those who see with their own eyes and feel with their own hearts.",
categories: ["independence", "thinking", "courage"],
tags: ["independence", "thinking", "prejudice", "courage"]
},
{
german: "Die Naturwissenschaft ohne Religion ist lahm, die Religion ohne Naturwissenschaft aber ist blind.",
english: "Science without religion is lame, religion without science is blind.",
categories: ["science", "religion", "balance"],
tags: ["science", "religion", "balance", "understanding"]
},
{
german: "Phantasie ist wichtiger als Wissen. Wissen ist begrenzt, Phantasie aber umfasst die ganze Welt.",
english: "Imagination is more important than knowledge. Knowledge is limited. Imagination embraces the entire world.",
categories: ["imagination", "knowledge", "creativity"],
tags: ["imagination", "knowledge", "creativity", "limitless"],
featured: true
},
{
german: "Gleichungen sind wichtiger für mich, weil Politik für die Gegenwart ist, aber eine Gleichung für die Ewigkeit.",
english: "Equations are more important to me, because politics is for the present, but an equation is for eternity.",
categories: ["mathematics", "politics", "eternity"],
tags: ["equations", "politics", "eternity", "mathematics"],
year: 1949
},
{
german: "Je mehr ich lerne, desto mehr erkenne ich, dass ich nichts weiß.",
english: "The more I learn, the more I realize how much I don't know.",
categories: ["learning", "humility", "knowledge"],
tags: ["learning", "humility", "knowledge", "wisdom"]
}
{
german:
'Die wichtigste Entscheidung, die du triffst, ist, ob du in einem freundlichen oder feindlichen Universum lebst.',
english:
'The most important decision we make is whether we believe we live in a friendly or hostile universe.',
categories: ['philosophy', 'mindset', 'universe'],
tags: ['universe', 'belief', 'decision', 'perspective'],
featured: true,
},
{
german: 'Wissenschaft ohne Religion ist lahm, Religion ohne Wissenschaft ist blind.',
english: 'Science without religion is lame, religion without science is blind.',
categories: ['science', 'religion', 'philosophy'],
tags: ['science', 'religion', 'balance', 'wisdom'],
year: 1941,
source: 'Science and Religion',
},
{
german: 'Ich habe keine besondere Begabung, sondern bin nur leidenschaftlich neugierig.',
english: 'I have no special talent. I am only passionately curious.',
categories: ['humility', 'curiosity', 'learning'],
tags: ['curiosity', 'talent', 'humility', 'passion'],
featured: true,
},
{
german:
'Das Leben ist wie Fahrrad fahren. Um die Balance zu halten, musst du in Bewegung bleiben.',
english: 'Life is like riding a bicycle. To keep your balance, you must keep moving.',
categories: ['life', 'balance', 'movement'],
tags: ['life', 'balance', 'movement', 'progress'],
year: 1930,
source: 'Brief an seinen Sohn Eduard',
featured: true,
},
{
german: 'Logik bringt dich von A nach B. Phantasie bringt dich überall hin.',
english: 'Logic will get you from A to B. Imagination will take you everywhere.',
categories: ['creativity', 'logic', 'wisdom'],
tags: ['logic', 'imagination', 'creativity', 'thinking'],
},
{
german: 'Wenn du es nicht einfach erklären kannst, verstehst du es nicht gut genug.',
english: "If you can't explain it simply, you don't understand it well enough.",
categories: ['knowledge', 'teaching', 'understanding'],
tags: ['simplicity', 'understanding', 'explanation', 'knowledge'],
featured: true,
},
{
german:
'Der Unterschied zwischen Genialität und Dummheit ist, dass Genialität ihre Grenzen hat.',
english: 'The difference between genius and stupidity is that genius has its limits.',
categories: ['humor', 'intelligence', 'wisdom'],
tags: ['genius', 'stupidity', 'limits', 'humor'],
},
{
german:
'Es gibt zwei Arten sein Leben zu leben: entweder so, als wäre nichts ein Wunder, oder so, als wäre alles ein Wunder.',
english:
'There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.',
categories: ['life', 'wonder', 'perspective'],
tags: ['miracle', 'life', 'wonder', 'perspective'],
featured: true,
},
{
german:
'Die Welt wird nicht bedroht von den Menschen, die böse sind, sondern von denen, die das Böse zulassen.',
english:
'The world will not be destroyed by those who do evil, but by those who watch them without doing anything.',
categories: ['evil', 'responsibility', 'action'],
tags: ['evil', 'inaction', 'responsibility', 'world'],
featured: true,
},
{
german: 'Ich denke niemals an die Zukunft. Sie kommt früh genug.',
english: 'I never think of the future. It comes soon enough.',
categories: ['time', 'future', 'present'],
tags: ['future', 'time', 'present', 'worry'],
},
{
german:
'Wer es in kleinen Dingen mit der Wahrheit nicht ernst nimmt, dem kann man auch in großen Dingen nicht vertrauen.',
english:
'Whoever is careless with the truth in small matters cannot be trusted with important matters.',
categories: ['truth', 'trust', 'integrity'],
tags: ['truth', 'trust', 'integrity', 'honesty'],
featured: true,
},
{
german: 'Die einzige Quelle des Wissens ist die Erfahrung.',
english: 'The only source of knowledge is experience.',
categories: ['knowledge', 'experience', 'learning'],
tags: ['knowledge', 'experience', 'learning', 'wisdom'],
},
{
german: 'Versuche nicht, ein erfolgreicher, sondern ein wertvoller Mensch zu werden.',
english: 'Try not to become a person of success, but rather try to become a person of value.',
categories: ['success', 'values', 'character'],
tags: ['success', 'value', 'character', 'purpose'],
featured: true,
},
{
german: 'Das Schönste, was wir erleben können, ist das Geheimnisvolle.',
english: 'The most beautiful thing we can experience is the mysterious.',
categories: ['mystery', 'beauty', 'wonder'],
tags: ['mystery', 'beauty', 'experience', 'wonder'],
source: 'Mein Weltbild',
year: 1931,
},
{
german: 'In der Mitte von Schwierigkeiten liegen Möglichkeiten.',
english: 'In the middle of difficulty lies opportunity.',
categories: ['opportunity', 'challenges', 'optimism'],
tags: ['difficulty', 'opportunity', 'challenges', 'optimism'],
featured: true,
},
{
german:
'Wenige sind imstande, von den Vorurteilen der Umgebung abweichende Meinungen gelassen auszusprechen.',
english: 'Few are those who see with their own eyes and feel with their own hearts.',
categories: ['independence', 'thinking', 'courage'],
tags: ['independence', 'thinking', 'prejudice', 'courage'],
},
{
german:
'Die Naturwissenschaft ohne Religion ist lahm, die Religion ohne Naturwissenschaft aber ist blind.',
english: 'Science without religion is lame, religion without science is blind.',
categories: ['science', 'religion', 'balance'],
tags: ['science', 'religion', 'balance', 'understanding'],
},
{
german:
'Phantasie ist wichtiger als Wissen. Wissen ist begrenzt, Phantasie aber umfasst die ganze Welt.',
english:
'Imagination is more important than knowledge. Knowledge is limited. Imagination embraces the entire world.',
categories: ['imagination', 'knowledge', 'creativity'],
tags: ['imagination', 'knowledge', 'creativity', 'limitless'],
featured: true,
},
{
german:
'Gleichungen sind wichtiger für mich, weil Politik für die Gegenwart ist, aber eine Gleichung für die Ewigkeit.',
english:
'Equations are more important to me, because politics is for the present, but an equation is for eternity.',
categories: ['mathematics', 'politics', 'eternity'],
tags: ['equations', 'politics', 'eternity', 'mathematics'],
year: 1949,
},
{
german: 'Je mehr ich lerne, desto mehr erkenne ich, dass ich nichts weiß.',
english: "The more I learn, the more I realize how much I don't know.",
categories: ['learning', 'humility', 'knowledge'],
tags: ['learning', 'humility', 'knowledge', 'wisdom'],
},
];
async function addMoreEinsteinQuotes() {
console.log('🧠 Adding more Albert Einstein quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Current status:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count current Einstein quotes
const currentEinsteinQuotes = deQuotes.quotes.filter(q =>
q.authorId === 'einstein-albert'
);
console.log(` Current Einstein quotes: ${currentEinsteinQuotes.length}`);
console.log(` New Einstein quotes to add: ${einsteinQuotes.length}`);
console.log('');
// Check for potential duplicates by comparing text similarity
console.log('🔍 Checking for potential duplicates...');
const duplicateWarnings = [];
einsteinQuotes.forEach((newQuote, index) => {
const normalizedNew = newQuote.german.toLowerCase();
currentEinsteinQuotes.forEach(existing => {
const normalizedExisting = existing.text.toLowerCase();
// Check if quotes are too similar
if (normalizedExisting.includes(normalizedNew.substring(0, 30)) ||
normalizedNew.includes(normalizedExisting.substring(0, 30))) {
duplicateWarnings.push({
new: newQuote.german.substring(0, 50) + '...',
existing: existing.text.substring(0, 50) + '...',
existingId: existing.id
});
}
});
});
if (duplicateWarnings.length > 0) {
console.log(`⚠️ Found potential duplicates or similar quotes:`);
duplicateWarnings.forEach(warning => {
console.log(` • New: "${warning.new}"`);
console.log(` Existing [${warning.existingId}]: "${warning.existing}"`);
});
console.log('');
} else {
console.log('✅ No potential duplicates found');
}
// Add new quotes
let nextIdNum = getNextQuoteId(deQuotes.quotes);
const authorId = 'einstein-albert';
einsteinQuotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: authorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: authorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source: quote.source === "Brief an seinen Sohn Eduard" ? "Letter to his son Eduard" :
quote.source === "Mein Weltbild" ? "The World As I See It" : quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500
};
// Remove undefined fields
Object.keys(germanQuote).forEach(key => {
if (germanQuote[key] === undefined) delete germanQuote[key];
});
Object.keys(englishQuote).forEach(key => {
if (englishQuote[key] === undefined) delete englishQuote[key];
});
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
// Save updated files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Successfully added ${einsteinQuotes.length} Einstein quotes!`);
console.log(`📊 Final statistics:`);
console.log(` • Total quotes: ${deQuotes.quotes.length}`);
console.log(` • Einstein quotes: ${currentEinsteinQuotes.length + einsteinQuotes.length} (was ${currentEinsteinQuotes.length}, now ${currentEinsteinQuotes.length + einsteinQuotes.length})`);
console.log(` • Einstein is now the author with the most quotes!`);
} catch (error) {
console.error('❌ Failed to add Einstein quotes:', error);
}
console.log('🧠 Adding more Albert Einstein quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Current status:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count current Einstein quotes
const currentEinsteinQuotes = deQuotes.quotes.filter((q) => q.authorId === 'einstein-albert');
console.log(` Current Einstein quotes: ${currentEinsteinQuotes.length}`);
console.log(` New Einstein quotes to add: ${einsteinQuotes.length}`);
console.log('');
// Check for potential duplicates by comparing text similarity
console.log('🔍 Checking for potential duplicates...');
const duplicateWarnings = [];
einsteinQuotes.forEach((newQuote, index) => {
const normalizedNew = newQuote.german.toLowerCase();
currentEinsteinQuotes.forEach((existing) => {
const normalizedExisting = existing.text.toLowerCase();
// Check if quotes are too similar
if (
normalizedExisting.includes(normalizedNew.substring(0, 30)) ||
normalizedNew.includes(normalizedExisting.substring(0, 30))
) {
duplicateWarnings.push({
new: newQuote.german.substring(0, 50) + '...',
existing: existing.text.substring(0, 50) + '...',
existingId: existing.id,
});
}
});
});
if (duplicateWarnings.length > 0) {
console.log(`⚠️ Found potential duplicates or similar quotes:`);
duplicateWarnings.forEach((warning) => {
console.log(` • New: "${warning.new}"`);
console.log(` Existing [${warning.existingId}]: "${warning.existing}"`);
});
console.log('');
} else {
console.log('✅ No potential duplicates found');
}
// Add new quotes
let nextIdNum = getNextQuoteId(deQuotes.quotes);
const authorId = 'einstein-albert';
einsteinQuotes.forEach((quote, index) => {
const quoteId = `q-${String(nextIdNum + index).padStart(3, '0')}`;
// German version
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: authorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500,
};
// English version
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: authorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source:
quote.source === 'Brief an seinen Sohn Eduard'
? 'Letter to his son Eduard'
: quote.source === 'Mein Weltbild'
? 'The World As I See It'
: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 4000) + 1500,
};
// Remove undefined fields
Object.keys(germanQuote).forEach((key) => {
if (germanQuote[key] === undefined) delete germanQuote[key];
});
Object.keys(englishQuote).forEach((key) => {
if (englishQuote[key] === undefined) delete englishQuote[key];
});
deQuotes.quotes.push(germanQuote);
enQuotes.quotes.push(englishQuote);
});
// Save updated files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Successfully added ${einsteinQuotes.length} Einstein quotes!`);
console.log(`📊 Final statistics:`);
console.log(` • Total quotes: ${deQuotes.quotes.length}`);
console.log(
` • Einstein quotes: ${currentEinsteinQuotes.length + einsteinQuotes.length} (was ${currentEinsteinQuotes.length}, now ${currentEinsteinQuotes.length + einsteinQuotes.length})`
);
console.log(` • Einstein is now the author with the most quotes!`);
} catch (error) {
console.error('❌ Failed to add Einstein quotes:', error);
}
}
if (require.main === module) {
addMoreEinsteinQuotes();
addMoreEinsteinQuotes();
}
module.exports = { addMoreEinsteinQuotes };
module.exports = { addMoreEinsteinQuotes };

View file

@ -9,207 +9,219 @@ const path = require('path');
// Find the highest quote ID to continue numbering
function getNextQuoteId(quotes) {
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return `q-${String(maxId + 1).padStart(3, '0')}`;
const maxId = quotes.reduce((max, quote) => {
const idNum = parseInt(quote.id.replace('q-', ''));
return idNum > max ? idNum : max;
}, 0);
return `q-${String(maxId + 1).padStart(3, '0')}`;
}
// Shakespeare quotes - German and English
const shakespeareQuotes = [
{
german: "Sein oder Nichtsein, das ist hier die Frage.",
english: "To be or not to be, that is the question.",
source: "Hamlet",
year: 1603,
categories: ["philosophy", "existence", "life"],
tags: ["existence", "death", "choice", "philosophy"],
featured: true
},
{
german: "Die ganze Welt ist eine Bühne, und alle Frauen und Männer bloße Spieler.",
english: "All the world's a stage, and all the men and women merely players.",
source: "Wie es euch gefällt",
year: 1599,
categories: ["life", "philosophy", "theater"],
tags: ["life", "roles", "performance", "world"],
featured: true
},
{
german: "Was ist in einem Namen? Was wir Rose nennen, würde unter jedem anderen Namen genauso süß duften.",
english: "What's in a name? That which we call a rose by any other name would smell as sweet.",
source: "Romeo und Julia",
year: 1595,
categories: ["love", "philosophy", "identity"],
tags: ["names", "identity", "love", "essence"]
},
{
german: "Liebe schaut nicht mit den Augen, sondern mit dem Geiste.",
english: "Love looks not with the eyes but with the mind.",
source: "Ein Sommernachtstraum",
year: 1595,
categories: ["love", "perception", "wisdom"],
tags: ["love", "perception", "heart", "mind"]
},
{
german: "Zweifel sind Verräter und lassen uns oft das Gute verlieren, das wir gewinnen könnten, wenn wir nur den Versuch nicht scheuten.",
english: "Our doubts are traitors, and make us lose the good we oft might win, by fearing to attempt.",
source: "Maß für Maß",
year: 1604,
categories: ["courage", "motivation", "fear"],
tags: ["doubt", "fear", "opportunity", "courage"]
},
{
german: "Es gibt mehr Dinge im Himmel und auf Erden, als eure Schulweisheit sich träumt.",
english: "There are more things in heaven and earth than are dreamt of in your philosophy.",
source: "Hamlet",
year: 1603,
categories: ["wisdom", "mystery", "knowledge"],
tags: ["mystery", "knowledge", "universe", "wisdom"]
},
{
german: "Wir wissen, was wir sind, aber nicht, was wir werden könnten.",
english: "We know what we are, but know not what we may be.",
source: "Hamlet",
year: 1603,
categories: ["potential", "self-knowledge", "future"],
tags: ["potential", "identity", "future", "growth"]
},
{
german: "Der Narr hält sich für weise, aber der Weise weiß, dass er ein Narr ist.",
english: "The fool doth think he is wise, but the wise man knows himself to be a fool.",
source: "Wie es euch gefällt",
year: 1599,
categories: ["wisdom", "humility", "knowledge"],
tags: ["wisdom", "humility", "knowledge", "foolishness"]
},
{
german: "Besser ein schlechter Witz als gar keine Unterhaltung.",
english: "Better a witty fool than a foolish wit.",
source: "Was ihr wollt",
year: 1601,
categories: ["humor", "wit", "intelligence"],
tags: ["wit", "humor", "intelligence", "entertainment"]
},
{
german: "Die Vergangenheit ist Prolog.",
english: "What's past is prologue.",
source: "Der Sturm",
year: 1611,
categories: ["time", "future", "philosophy"],
tags: ["past", "future", "time", "beginning"]
},
{
german: "Macht ist gefährlich, außer sie liegt in den Händen derer, die sie nicht begehren.",
english: "Power is dangerous unless you have humility.",
source: "Measure for Measure",
year: 1604,
categories: ["power", "humility", "politics"],
tags: ["power", "humility", "leadership", "danger"]
},
{
german: "Das Gewissen macht Feiglinge aus uns allen.",
english: "Conscience does make cowards of us all.",
source: "Hamlet",
year: 1603,
categories: ["morality", "conscience", "courage"],
tags: ["conscience", "morality", "courage", "guilt"]
}
{
german: 'Sein oder Nichtsein, das ist hier die Frage.',
english: 'To be or not to be, that is the question.',
source: 'Hamlet',
year: 1603,
categories: ['philosophy', 'existence', 'life'],
tags: ['existence', 'death', 'choice', 'philosophy'],
featured: true,
},
{
german: 'Die ganze Welt ist eine Bühne, und alle Frauen und Männer bloße Spieler.',
english: "All the world's a stage, and all the men and women merely players.",
source: 'Wie es euch gefällt',
year: 1599,
categories: ['life', 'philosophy', 'theater'],
tags: ['life', 'roles', 'performance', 'world'],
featured: true,
},
{
german:
'Was ist in einem Namen? Was wir Rose nennen, würde unter jedem anderen Namen genauso süß duften.',
english: "What's in a name? That which we call a rose by any other name would smell as sweet.",
source: 'Romeo und Julia',
year: 1595,
categories: ['love', 'philosophy', 'identity'],
tags: ['names', 'identity', 'love', 'essence'],
},
{
german: 'Liebe schaut nicht mit den Augen, sondern mit dem Geiste.',
english: 'Love looks not with the eyes but with the mind.',
source: 'Ein Sommernachtstraum',
year: 1595,
categories: ['love', 'perception', 'wisdom'],
tags: ['love', 'perception', 'heart', 'mind'],
},
{
german:
'Zweifel sind Verräter und lassen uns oft das Gute verlieren, das wir gewinnen könnten, wenn wir nur den Versuch nicht scheuten.',
english:
'Our doubts are traitors, and make us lose the good we oft might win, by fearing to attempt.',
source: 'Maß für Maß',
year: 1604,
categories: ['courage', 'motivation', 'fear'],
tags: ['doubt', 'fear', 'opportunity', 'courage'],
},
{
german: 'Es gibt mehr Dinge im Himmel und auf Erden, als eure Schulweisheit sich träumt.',
english: 'There are more things in heaven and earth than are dreamt of in your philosophy.',
source: 'Hamlet',
year: 1603,
categories: ['wisdom', 'mystery', 'knowledge'],
tags: ['mystery', 'knowledge', 'universe', 'wisdom'],
},
{
german: 'Wir wissen, was wir sind, aber nicht, was wir werden könnten.',
english: 'We know what we are, but know not what we may be.',
source: 'Hamlet',
year: 1603,
categories: ['potential', 'self-knowledge', 'future'],
tags: ['potential', 'identity', 'future', 'growth'],
},
{
german: 'Der Narr hält sich für weise, aber der Weise weiß, dass er ein Narr ist.',
english: 'The fool doth think he is wise, but the wise man knows himself to be a fool.',
source: 'Wie es euch gefällt',
year: 1599,
categories: ['wisdom', 'humility', 'knowledge'],
tags: ['wisdom', 'humility', 'knowledge', 'foolishness'],
},
{
german: 'Besser ein schlechter Witz als gar keine Unterhaltung.',
english: 'Better a witty fool than a foolish wit.',
source: 'Was ihr wollt',
year: 1601,
categories: ['humor', 'wit', 'intelligence'],
tags: ['wit', 'humor', 'intelligence', 'entertainment'],
},
{
german: 'Die Vergangenheit ist Prolog.',
english: "What's past is prologue.",
source: 'Der Sturm',
year: 1611,
categories: ['time', 'future', 'philosophy'],
tags: ['past', 'future', 'time', 'beginning'],
},
{
german: 'Macht ist gefährlich, außer sie liegt in den Händen derer, die sie nicht begehren.',
english: 'Power is dangerous unless you have humility.',
source: 'Measure for Measure',
year: 1604,
categories: ['power', 'humility', 'politics'],
tags: ['power', 'humility', 'leadership', 'danger'],
},
{
german: 'Das Gewissen macht Feiglinge aus uns allen.',
english: 'Conscience does make cowards of us all.',
source: 'Hamlet',
year: 1603,
categories: ['morality', 'conscience', 'courage'],
tags: ['conscience', 'morality', 'courage', 'guilt'],
},
];
async function addShakespeareQuotes() {
console.log('📚 Adding Shakespeare quotes (currently has 0!)...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`Current German quotes: ${deQuotes.quotes.length}`);
console.log(`Current English quotes: ${enQuotes.quotes.length}`);
// Check if Shakespeare author exists
const deAuthorsPath = path.join(__dirname, '../content/data/de/authors.json');
const deAuthors = JSON.parse(fs.readFileSync(deAuthorsPath, 'utf8'));
const shakespeareAuthor = deAuthors.authors.find(a =>
a.name.includes('Shakespeare') || a.id.includes('shakespeare')
);
let authorId = 'shakespeare-william';
if (shakespeareAuthor) {
authorId = shakespeareAuthor.id;
console.log(`Found Shakespeare author: ${shakespeareAuthor.name} (${authorId})`);
} else {
console.log('⚠️ Shakespeare author not found, using default ID: shakespeare-william');
}
// Add German quotes
let nextId = getNextQuoteId(deQuotes.quotes);
shakespeareQuotes.forEach((quote, index) => {
const quoteId = `q-${String(parseInt(nextId.replace('q-', '')) + index).padStart(3, '0')}`;
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: authorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000
};
deQuotes.quotes.push(germanQuote);
});
// Add English quotes
shakespeareQuotes.forEach((quote, index) => {
const quoteId = `q-${String(parseInt(nextId.replace('q-', '')) + index).padStart(3, '0')}`;
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: authorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source: quote.source === "Wie es euch gefällt" ? "As You Like It" :
quote.source === "Romeo und Julia" ? "Romeo and Juliet" :
quote.source === "Ein Sommernachtstraum" ? "A Midsummer Night's Dream" :
quote.source === "Maß für Maß" ? "Measure for Measure" :
quote.source === "Was ihr wollt" ? "Twelfth Night" :
quote.source === "Der Sturm" ? "The Tempest" : quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000
};
enQuotes.quotes.push(englishQuote);
});
// Save files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Added ${shakespeareQuotes.length} Shakespeare quotes to both languages`);
console.log(`📊 New totals: German ${deQuotes.quotes.length}, English ${enQuotes.quotes.length}`);
} catch (error) {
console.error('❌ Failed to add Shakespeare quotes:', error);
}
console.log('📚 Adding Shakespeare quotes (currently has 0!)...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`Current German quotes: ${deQuotes.quotes.length}`);
console.log(`Current English quotes: ${enQuotes.quotes.length}`);
// Check if Shakespeare author exists
const deAuthorsPath = path.join(__dirname, '../content/data/de/authors.json');
const deAuthors = JSON.parse(fs.readFileSync(deAuthorsPath, 'utf8'));
const shakespeareAuthor = deAuthors.authors.find(
(a) => a.name.includes('Shakespeare') || a.id.includes('shakespeare')
);
let authorId = 'shakespeare-william';
if (shakespeareAuthor) {
authorId = shakespeareAuthor.id;
console.log(`Found Shakespeare author: ${shakespeareAuthor.name} (${authorId})`);
} else {
console.log('⚠️ Shakespeare author not found, using default ID: shakespeare-william');
}
// Add German quotes
let nextId = getNextQuoteId(deQuotes.quotes);
shakespeareQuotes.forEach((quote, index) => {
const quoteId = `q-${String(parseInt(nextId.replace('q-', '')) + index).padStart(3, '0')}`;
const germanQuote = {
id: quoteId,
text: quote.german,
authorId: authorId,
language: 'de',
categories: quote.categories,
tags: quote.tags,
source: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000,
};
deQuotes.quotes.push(germanQuote);
});
// Add English quotes
shakespeareQuotes.forEach((quote, index) => {
const quoteId = `q-${String(parseInt(nextId.replace('q-', '')) + index).padStart(3, '0')}`;
const englishQuote = {
id: quoteId,
text: quote.english,
authorId: authorId,
language: 'en',
categories: quote.categories,
tags: quote.tags,
source:
quote.source === 'Wie es euch gefällt'
? 'As You Like It'
: quote.source === 'Romeo und Julia'
? 'Romeo and Juliet'
: quote.source === 'Ein Sommernachtstraum'
? "A Midsummer Night's Dream"
: quote.source === 'Maß für Maß'
? 'Measure for Measure'
: quote.source === 'Was ihr wollt'
? 'Twelfth Night'
: quote.source === 'Der Sturm'
? 'The Tempest'
: quote.source,
year: quote.year,
dateAdded: new Date().toISOString().split('T')[0],
featured: quote.featured || false,
likes: Math.floor(Math.random() * 3000) + 1000,
};
enQuotes.quotes.push(englishQuote);
});
// Save files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Added ${shakespeareQuotes.length} Shakespeare quotes to both languages`);
console.log(
`📊 New totals: German ${deQuotes.quotes.length}, English ${enQuotes.quotes.length}`
);
} catch (error) {
console.error('❌ Failed to add Shakespeare quotes:', error);
}
}
if (require.main === module) {
addShakespeareQuotes();
addShakespeareQuotes();
}
module.exports = { addShakespeareQuotes };
module.exports = { addShakespeareQuotes };

View file

@ -8,82 +8,81 @@ const fs = require('fs');
const path = require('path');
function analyzeAuthors() {
console.log('📊 Analyzing author distribution...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const deAuthorsPath = path.join(__dirname, '../content/data/de/authors.json');
try {
const quotesData = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const authorsData = JSON.parse(fs.readFileSync(deAuthorsPath, 'utf8'));
// Count quotes per author
const authorQuoteCounts = {};
quotesData.quotes.forEach(quote => {
if (authorQuoteCounts[quote.authorId]) {
authorQuoteCounts[quote.authorId]++;
} else {
authorQuoteCounts[quote.authorId] = 1;
}
});
// Get author names
const authorNames = {};
authorsData.authors.forEach(author => {
authorNames[author.id] = author.name;
});
// Sort authors by quote count
const authorStats = Object.entries(authorQuoteCounts)
.map(([id, count]) => ({
id,
name: authorNames[id] || id,
count
}))
.sort((a, b) => b.count - a.count);
console.log('🏆 Top authors by quote count:');
authorStats.slice(0, 20).forEach((author, index) => {
console.log(`${index + 1}. ${author.name}: ${author.count} quotes`);
});
console.log('\n📈 Authors with fewer quotes (good candidates for expansion):');
const fewQuotes = authorStats.filter(a => a.count <= 3).slice(0, 15);
fewQuotes.forEach(author => {
console.log(`${author.name}: ${author.count} quotes`);
});
// Famous authors that should have more quotes
const famousAuthors = [
'einstein-albert',
'shakespeare-william',
'nietzsche-friedrich',
'goethe-johann',
'twain-mark',
'buddha',
'konfuzius',
'platon',
'aristoteles',
'gandhi-mahatma'
];
console.log('\n⭐ Major authors current quote counts:');
famousAuthors.forEach(id => {
const count = authorQuoteCounts[id] || 0;
const name = authorNames[id] || id;
console.log(`${name}: ${count} quotes`);
});
return { authorStats, authorQuoteCounts, authorNames };
} catch (error) {
console.error('❌ Analysis failed:', error);
return null;
}
console.log('📊 Analyzing author distribution...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const deAuthorsPath = path.join(__dirname, '../content/data/de/authors.json');
try {
const quotesData = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const authorsData = JSON.parse(fs.readFileSync(deAuthorsPath, 'utf8'));
// Count quotes per author
const authorQuoteCounts = {};
quotesData.quotes.forEach((quote) => {
if (authorQuoteCounts[quote.authorId]) {
authorQuoteCounts[quote.authorId]++;
} else {
authorQuoteCounts[quote.authorId] = 1;
}
});
// Get author names
const authorNames = {};
authorsData.authors.forEach((author) => {
authorNames[author.id] = author.name;
});
// Sort authors by quote count
const authorStats = Object.entries(authorQuoteCounts)
.map(([id, count]) => ({
id,
name: authorNames[id] || id,
count,
}))
.sort((a, b) => b.count - a.count);
console.log('🏆 Top authors by quote count:');
authorStats.slice(0, 20).forEach((author, index) => {
console.log(`${index + 1}. ${author.name}: ${author.count} quotes`);
});
console.log('\n📈 Authors with fewer quotes (good candidates for expansion):');
const fewQuotes = authorStats.filter((a) => a.count <= 3).slice(0, 15);
fewQuotes.forEach((author) => {
console.log(`${author.name}: ${author.count} quotes`);
});
// Famous authors that should have more quotes
const famousAuthors = [
'einstein-albert',
'shakespeare-william',
'nietzsche-friedrich',
'goethe-johann',
'twain-mark',
'buddha',
'konfuzius',
'platon',
'aristoteles',
'gandhi-mahatma',
];
console.log('\n⭐ Major authors current quote counts:');
famousAuthors.forEach((id) => {
const count = authorQuoteCounts[id] || 0;
const name = authorNames[id] || id;
console.log(`${name}: ${count} quotes`);
});
return { authorStats, authorQuoteCounts, authorNames };
} catch (error) {
console.error('❌ Analysis failed:', error);
return null;
}
}
if (require.main === module) {
analyzeAuthors();
analyzeAuthors();
}
module.exports = { analyzeAuthors };
module.exports = { analyzeAuthors };

View file

@ -8,193 +8,192 @@ const fs = require('fs');
const path = require('path');
function findDuplicates() {
console.log('🔍 Checking for duplicate quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Total German quotes: ${deQuotes.quotes.length}`);
console.log(`📊 Total English quotes: ${enQuotes.quotes.length}`);
console.log('');
// Check for duplicate IDs
console.log('1⃣ Checking for duplicate IDs...');
const deIds = new Map();
const enIds = new Map();
const duplicateIds = [];
deQuotes.quotes.forEach((quote, index) => {
if (deIds.has(quote.id)) {
duplicateIds.push({
id: quote.id,
firstIndex: deIds.get(quote.id),
secondIndex: index,
language: 'de'
});
} else {
deIds.set(quote.id, index);
}
});
enQuotes.quotes.forEach((quote, index) => {
if (enIds.has(quote.id)) {
duplicateIds.push({
id: quote.id,
firstIndex: enIds.get(quote.id),
secondIndex: index,
language: 'en'
});
} else {
enIds.set(quote.id, index);
}
});
if (duplicateIds.length > 0) {
console.log(`❌ Found ${duplicateIds.length} duplicate IDs:`);
duplicateIds.forEach(dup => {
console.log(`${dup.id} appears at indices ${dup.firstIndex} and ${dup.secondIndex} in ${dup.language}`);
});
} else {
console.log('✅ No duplicate IDs found');
}
// Check for duplicate text content (German)
console.log('\n2⃣ Checking for duplicate German texts...');
const deTexts = new Map();
const duplicateTexts = [];
deQuotes.quotes.forEach((quote, index) => {
const normalizedText = quote.text.toLowerCase().trim();
if (deTexts.has(normalizedText)) {
const firstQuote = deQuotes.quotes[deTexts.get(normalizedText)];
duplicateTexts.push({
text: quote.text.substring(0, 60) + '...',
ids: [firstQuote.id, quote.id],
authors: [firstQuote.authorId, quote.authorId],
indices: [deTexts.get(normalizedText), index]
});
} else {
deTexts.set(normalizedText, index);
}
});
if (duplicateTexts.length > 0) {
console.log(`⚠️ Found ${duplicateTexts.length} duplicate German texts:`);
duplicateTexts.forEach(dup => {
console.log(` • "${dup.text}"`);
console.log(` IDs: ${dup.ids.join(', ')} | Authors: ${dup.authors.join(', ')}`);
});
} else {
console.log('✅ No duplicate German texts found');
}
// Check for similar texts (might be slight variations)
console.log('\n3⃣ Checking for similar quotes (potential variations)...');
const similarQuotes = [];
const processedPairs = new Set();
deQuotes.quotes.forEach((quote1, i) => {
deQuotes.quotes.forEach((quote2, j) => {
if (i >= j) return; // Skip same quote and already processed pairs
const pairKey = `${i}-${j}`;
if (processedPairs.has(pairKey)) return;
processedPairs.add(pairKey);
const text1 = quote1.text.toLowerCase();
const text2 = quote2.text.toLowerCase();
// Check if texts are very similar (share significant portion)
const words1 = text1.split(/\s+/);
const words2 = text2.split(/\s+/);
if (words1.length > 5 && words2.length > 5) {
const commonWords = words1.filter(word =>
word.length > 4 && text2.includes(word)
);
const similarity = commonWords.length / Math.min(words1.length, words2.length);
if (similarity > 0.7 && quote1.authorId === quote2.authorId) {
similarQuotes.push({
quote1: {
id: quote1.id,
text: quote1.text.substring(0, 50) + '...',
author: quote1.authorId
},
quote2: {
id: quote2.id,
text: quote2.text.substring(0, 50) + '...',
author: quote2.authorId
},
similarity: Math.round(similarity * 100)
});
}
}
});
});
if (similarQuotes.length > 0) {
console.log(`🔸 Found ${similarQuotes.length} potentially similar quotes from same authors:`);
similarQuotes.slice(0, 10).forEach(pair => {
console.log(`\n Author: ${pair.quote1.author} (${pair.similarity}% similar)`);
console.log(` • [${pair.quote1.id}] "${pair.quote1.text}"`);
console.log(` • [${pair.quote2.id}] "${pair.quote2.text}"`);
});
if (similarQuotes.length > 10) {
console.log(` ... and ${similarQuotes.length - 10} more`);
}
} else {
console.log('✅ No suspiciously similar quotes found');
}
// Check author distribution
console.log('\n4⃣ Author quote distribution:');
const authorCounts = new Map();
deQuotes.quotes.forEach(quote => {
const count = authorCounts.get(quote.authorId) || 0;
authorCounts.set(quote.authorId, count + 1);
});
const sortedAuthors = Array.from(authorCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 15);
console.log('Top 15 authors by quote count:');
sortedAuthors.forEach(([author, count], index) => {
console.log(` ${index + 1}. ${author}: ${count} quotes`);
});
// Summary
console.log('\n📈 Summary:');
console.log(` • Total quotes: ${deQuotes.quotes.length}`);
console.log(` • Unique IDs: ${deIds.size}`);
console.log(` • Unique texts: ${deTexts.size}`);
console.log(` • Duplicate IDs: ${duplicateIds.length}`);
console.log(` • Duplicate texts: ${duplicateTexts.length}`);
console.log(` • Authors with quotes: ${authorCounts.size}`);
return {
duplicateIds,
duplicateTexts,
similarQuotes,
totalQuotes: deQuotes.quotes.length,
uniqueIds: deIds.size,
uniqueTexts: deTexts.size
};
} catch (error) {
console.error('❌ Error checking duplicates:', error);
return null;
}
console.log('🔍 Checking for duplicate quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Total German quotes: ${deQuotes.quotes.length}`);
console.log(`📊 Total English quotes: ${enQuotes.quotes.length}`);
console.log('');
// Check for duplicate IDs
console.log('1⃣ Checking for duplicate IDs...');
const deIds = new Map();
const enIds = new Map();
const duplicateIds = [];
deQuotes.quotes.forEach((quote, index) => {
if (deIds.has(quote.id)) {
duplicateIds.push({
id: quote.id,
firstIndex: deIds.get(quote.id),
secondIndex: index,
language: 'de',
});
} else {
deIds.set(quote.id, index);
}
});
enQuotes.quotes.forEach((quote, index) => {
if (enIds.has(quote.id)) {
duplicateIds.push({
id: quote.id,
firstIndex: enIds.get(quote.id),
secondIndex: index,
language: 'en',
});
} else {
enIds.set(quote.id, index);
}
});
if (duplicateIds.length > 0) {
console.log(`❌ Found ${duplicateIds.length} duplicate IDs:`);
duplicateIds.forEach((dup) => {
console.log(
`${dup.id} appears at indices ${dup.firstIndex} and ${dup.secondIndex} in ${dup.language}`
);
});
} else {
console.log('✅ No duplicate IDs found');
}
// Check for duplicate text content (German)
console.log('\n2⃣ Checking for duplicate German texts...');
const deTexts = new Map();
const duplicateTexts = [];
deQuotes.quotes.forEach((quote, index) => {
const normalizedText = quote.text.toLowerCase().trim();
if (deTexts.has(normalizedText)) {
const firstQuote = deQuotes.quotes[deTexts.get(normalizedText)];
duplicateTexts.push({
text: quote.text.substring(0, 60) + '...',
ids: [firstQuote.id, quote.id],
authors: [firstQuote.authorId, quote.authorId],
indices: [deTexts.get(normalizedText), index],
});
} else {
deTexts.set(normalizedText, index);
}
});
if (duplicateTexts.length > 0) {
console.log(`⚠️ Found ${duplicateTexts.length} duplicate German texts:`);
duplicateTexts.forEach((dup) => {
console.log(` • "${dup.text}"`);
console.log(` IDs: ${dup.ids.join(', ')} | Authors: ${dup.authors.join(', ')}`);
});
} else {
console.log('✅ No duplicate German texts found');
}
// Check for similar texts (might be slight variations)
console.log('\n3⃣ Checking for similar quotes (potential variations)...');
const similarQuotes = [];
const processedPairs = new Set();
deQuotes.quotes.forEach((quote1, i) => {
deQuotes.quotes.forEach((quote2, j) => {
if (i >= j) return; // Skip same quote and already processed pairs
const pairKey = `${i}-${j}`;
if (processedPairs.has(pairKey)) return;
processedPairs.add(pairKey);
const text1 = quote1.text.toLowerCase();
const text2 = quote2.text.toLowerCase();
// Check if texts are very similar (share significant portion)
const words1 = text1.split(/\s+/);
const words2 = text2.split(/\s+/);
if (words1.length > 5 && words2.length > 5) {
const commonWords = words1.filter((word) => word.length > 4 && text2.includes(word));
const similarity = commonWords.length / Math.min(words1.length, words2.length);
if (similarity > 0.7 && quote1.authorId === quote2.authorId) {
similarQuotes.push({
quote1: {
id: quote1.id,
text: quote1.text.substring(0, 50) + '...',
author: quote1.authorId,
},
quote2: {
id: quote2.id,
text: quote2.text.substring(0, 50) + '...',
author: quote2.authorId,
},
similarity: Math.round(similarity * 100),
});
}
}
});
});
if (similarQuotes.length > 0) {
console.log(`🔸 Found ${similarQuotes.length} potentially similar quotes from same authors:`);
similarQuotes.slice(0, 10).forEach((pair) => {
console.log(`\n Author: ${pair.quote1.author} (${pair.similarity}% similar)`);
console.log(` • [${pair.quote1.id}] "${pair.quote1.text}"`);
console.log(` • [${pair.quote2.id}] "${pair.quote2.text}"`);
});
if (similarQuotes.length > 10) {
console.log(` ... and ${similarQuotes.length - 10} more`);
}
} else {
console.log('✅ No suspiciously similar quotes found');
}
// Check author distribution
console.log('\n4⃣ Author quote distribution:');
const authorCounts = new Map();
deQuotes.quotes.forEach((quote) => {
const count = authorCounts.get(quote.authorId) || 0;
authorCounts.set(quote.authorId, count + 1);
});
const sortedAuthors = Array.from(authorCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 15);
console.log('Top 15 authors by quote count:');
sortedAuthors.forEach(([author, count], index) => {
console.log(` ${index + 1}. ${author}: ${count} quotes`);
});
// Summary
console.log('\n📈 Summary:');
console.log(` • Total quotes: ${deQuotes.quotes.length}`);
console.log(` • Unique IDs: ${deIds.size}`);
console.log(` • Unique texts: ${deTexts.size}`);
console.log(` • Duplicate IDs: ${duplicateIds.length}`);
console.log(` • Duplicate texts: ${duplicateTexts.length}`);
console.log(` • Authors with quotes: ${authorCounts.size}`);
return {
duplicateIds,
duplicateTexts,
similarQuotes,
totalQuotes: deQuotes.quotes.length,
uniqueIds: deIds.size,
uniqueTexts: deTexts.size,
};
} catch (error) {
console.error('❌ Error checking duplicates:', error);
return null;
}
}
if (require.main === module) {
findDuplicates();
findDuplicates();
}
module.exports = { findDuplicates };
module.exports = { findDuplicates };

View file

@ -9,140 +9,144 @@ const path = require('path');
// English translations for key quotes (partial sample)
const translations = {
'q-001': {
text: 'Imagination is more important than knowledge, because knowledge is limited.',
context: 'Einstein emphasized the importance of creativity in scientific research.'
},
'q-002': {
text: 'Two things are infinite: the universe and human stupidity; and I\'m not sure about the universe.',
context: 'One of Einstein\'s most famous humorous observations about humanity.'
},
'q-003': {
text: 'God does not play dice.',
context: 'Einstein\'s famous critique of quantum mechanics.'
},
'q-004': {
text: 'One sees clearly only with the heart. What is essential is invisible to the eye.',
context: 'From "The Little Prince" - about the importance of emotional understanding.'
},
'q-005': {
text: 'You are responsible for what you have tamed.',
context: 'From "The Little Prince" - about responsibility and relationships.'
},
'q-007': {
text: 'The journey is the destination.',
context: 'Goethe\'s philosophy about the process being more important than the goal.'
},
'q-009': {
text: 'What does not kill me makes me stronger.',
context: 'Nietzsche\'s philosophy about resilience and growth through adversity.'
}
'q-001': {
text: 'Imagination is more important than knowledge, because knowledge is limited.',
context: 'Einstein emphasized the importance of creativity in scientific research.',
},
'q-002': {
text: "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.",
context: "One of Einstein's most famous humorous observations about humanity.",
},
'q-003': {
text: 'God does not play dice.',
context: "Einstein's famous critique of quantum mechanics.",
},
'q-004': {
text: 'One sees clearly only with the heart. What is essential is invisible to the eye.',
context: 'From "The Little Prince" - about the importance of emotional understanding.',
},
'q-005': {
text: 'You are responsible for what you have tamed.',
context: 'From "The Little Prince" - about responsibility and relationships.',
},
'q-007': {
text: 'The journey is the destination.',
context: "Goethe's philosophy about the process being more important than the goal.",
},
'q-009': {
text: 'What does not kill me makes me stronger.',
context: "Nietzsche's philosophy about resilience and growth through adversity.",
},
};
// English author translations
const authorTranslations = {
'einstein-albert': {
profession: ['Physicist', 'Philosopher'],
biography: {
short: 'Theoretical physicist who developed the theory of relativity.'
}
},
'saint-exupery-antoine': {
profession: ['Writer', 'Pilot'],
biography: {
short: 'French writer and pilot, known for "The Little Prince".'
}
},
'goethe-johann': {
profession: ['Poet', 'Natural Philosopher'],
biography: {
short: 'German poet and natural philosopher, one of the most significant creators of German-language poetry.'
}
},
'nietzsche-friedrich': {
profession: ['Philosopher', 'Philologist'],
biography: {
short: 'German philosopher and classical philologist.'
}
}
'einstein-albert': {
profession: ['Physicist', 'Philosopher'],
biography: {
short: 'Theoretical physicist who developed the theory of relativity.',
},
},
'saint-exupery-antoine': {
profession: ['Writer', 'Pilot'],
biography: {
short: 'French writer and pilot, known for "The Little Prince".',
},
},
'goethe-johann': {
profession: ['Poet', 'Natural Philosopher'],
biography: {
short:
'German poet and natural philosopher, one of the most significant creators of German-language poetry.',
},
},
'nietzsche-friedrich': {
profession: ['Philosopher', 'Philologist'],
biography: {
short: 'German philosopher and classical philologist.',
},
},
};
function createEnglishTranslations() {
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
const deAuthorsPath = path.join(__dirname, '../content/data/de/authors.json');
const enAuthorsPath = path.join(__dirname, '../content/data/en/authors.json');
// Load German data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const deAuthors = JSON.parse(fs.readFileSync(deAuthorsPath, 'utf8'));
console.log(`📚 Processing ${deQuotes.quotes.length} German quotes...`);
// Create English quotes - for now, use available translations and keep German for others
const enQuotes = {
quotes: deQuotes.quotes.map(quote => {
const translation = translations[quote.id];
return {
...quote,
language: 'en',
text: translation ? translation.text : quote.text, // Use translation if available
context: translation?.context || quote.context,
// Translate some common source titles
source: quote.source === 'Der kleine Prinz' ? 'The Little Prince' : quote.source
};
})
};
console.log(`📚 Created ${enQuotes.quotes.length} English quotes (${Object.keys(translations).length} fully translated)`);
// Create English authors
const enAuthors = {
authors: deAuthors.authors.map(author => {
const translation = authorTranslations[author.id];
return {
...author,
profession: translation?.profession || author.profession,
biography: translation?.biography || author.biography
};
})
};
console.log(`👥 Created ${enAuthors.authors.length} English authors (${Object.keys(authorTranslations).length} fully translated)`);
// Write English files
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
fs.writeFileSync(enAuthorsPath, JSON.stringify(enAuthors, null, 2), 'utf8');
console.log('✅ English translation files created successfully!');
console.log('📝 Note: This is a partial translation. Many quotes still need translation.');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
const deAuthorsPath = path.join(__dirname, '../content/data/de/authors.json');
const enAuthorsPath = path.join(__dirname, '../content/data/en/authors.json');
// Load German data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const deAuthors = JSON.parse(fs.readFileSync(deAuthorsPath, 'utf8'));
console.log(`📚 Processing ${deQuotes.quotes.length} German quotes...`);
// Create English quotes - for now, use available translations and keep German for others
const enQuotes = {
quotes: deQuotes.quotes.map((quote) => {
const translation = translations[quote.id];
return {
...quote,
language: 'en',
text: translation ? translation.text : quote.text, // Use translation if available
context: translation?.context || quote.context,
// Translate some common source titles
source: quote.source === 'Der kleine Prinz' ? 'The Little Prince' : quote.source,
};
}),
};
console.log(
`📚 Created ${enQuotes.quotes.length} English quotes (${Object.keys(translations).length} fully translated)`
);
// Create English authors
const enAuthors = {
authors: deAuthors.authors.map((author) => {
const translation = authorTranslations[author.id];
return {
...author,
profession: translation?.profession || author.profession,
biography: translation?.biography || author.biography,
};
}),
};
console.log(
`👥 Created ${enAuthors.authors.length} English authors (${Object.keys(authorTranslations).length} fully translated)`
);
// Write English files
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
fs.writeFileSync(enAuthorsPath, JSON.stringify(enAuthors, null, 2), 'utf8');
console.log('✅ English translation files created successfully!');
console.log('📝 Note: This is a partial translation. Many quotes still need translation.');
}
function analyzeCurrentEnglishFiles() {
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
const enAuthorsPath = path.join(__dirname, '../content/data/en/authors.json');
try {
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
const enAuthors = JSON.parse(fs.readFileSync(enAuthorsPath, 'utf8'));
console.log(`📚 Current English quotes: ${enQuotes.quotes.length}`);
console.log(`👥 Current English authors: ${enAuthors.authors.length}`);
// Check language setting
const languageCheck = enQuotes.quotes.slice(0, 5).map(q => `${q.id}: ${q.language}`);
console.log(`🌍 Language settings:`, languageCheck);
} catch (error) {
console.error('Error analyzing English files:', error.message);
}
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
const enAuthorsPath = path.join(__dirname, '../content/data/en/authors.json');
try {
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
const enAuthors = JSON.parse(fs.readFileSync(enAuthorsPath, 'utf8'));
console.log(`📚 Current English quotes: ${enQuotes.quotes.length}`);
console.log(`👥 Current English authors: ${enAuthors.authors.length}`);
// Check language setting
const languageCheck = enQuotes.quotes.slice(0, 5).map((q) => `${q.id}: ${q.language}`);
console.log(`🌍 Language settings:`, languageCheck);
} catch (error) {
console.error('Error analyzing English files:', error.message);
}
}
if (require.main === module) {
console.log('🔍 Analyzing current English files...');
analyzeCurrentEnglishFiles();
console.log('\n🔄 Creating English translations...');
createEnglishTranslations();
console.log('🔍 Analyzing current English files...');
analyzeCurrentEnglishFiles();
console.log('\n🔄 Creating English translations...');
createEnglishTranslations();
}
module.exports = { createEnglishTranslations };
module.exports = { createEnglishTranslations };

View file

@ -9,122 +9,121 @@ const path = require('path');
// Hardcoded quotes from contentLoader.ts
const hardcodedQuotes = [
{
id: 'q1',
text: 'Phantasie ist wichtiger als Wissen, denn Wissen ist begrenzt.',
authorId: 'einstein-albert',
categories: ['wisdom', 'creativity'],
tags: ['imagination', 'knowledge'],
featured: true,
likes: 1250,
},
{
id: 'q2',
text: 'Zwei Dinge sind unendlich: das Universum und die menschliche Dummheit; aber bei dem Universum bin ich mir noch nicht ganz sicher.',
authorId: 'einstein-albert',
categories: ['humor', 'wisdom'],
featured: true,
likes: 2340,
},
// Add all other hardcoded quotes...
{
id: 'q1',
text: 'Phantasie ist wichtiger als Wissen, denn Wissen ist begrenzt.',
authorId: 'einstein-albert',
categories: ['wisdom', 'creativity'],
tags: ['imagination', 'knowledge'],
featured: true,
likes: 1250,
},
{
id: 'q2',
text: 'Zwei Dinge sind unendlich: das Universum und die menschliche Dummheit; aber bei dem Universum bin ich mir noch nicht ganz sicher.',
authorId: 'einstein-albert',
categories: ['humor', 'wisdom'],
featured: true,
likes: 2340,
},
// Add all other hardcoded quotes...
];
// Hardcoded authors from contentLoader.ts
const hardcodedAuthors = [
{
id: 'einstein-albert',
name: 'Albert Einstein',
profession: ['Physiker', 'Philosoph'],
biography: {
short: 'Theoretischer Physiker, der die Relativitätstheorie entwickelte.',
},
lifespan: { birth: '1879-03-14', death: '1955-04-18' },
verified: true,
featured: true,
},
// Add all other hardcoded authors...
{
id: 'einstein-albert',
name: 'Albert Einstein',
profession: ['Physiker', 'Philosoph'],
biography: {
short: 'Theoretischer Physiker, der die Relativitätstheorie entwickelte.',
},
lifespan: { birth: '1879-03-14', death: '1955-04-18' },
verified: true,
featured: true,
},
// Add all other hardcoded authors...
];
function mergeQuotes() {
const quotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const authorsPath = path.join(__dirname, '../content/data/de/authors.json');
// Load existing JSON data
const quotesData = JSON.parse(fs.readFileSync(quotesPath, 'utf8'));
const authorsData = JSON.parse(fs.readFileSync(authorsPath, 'utf8'));
console.log(`📚 Current German quotes: ${quotesData.quotes.length}`);
console.log(`👥 Current German authors: ${authorsData.authors.length}`);
// Check if hardcoded quotes are missing from JSON
const existingQuoteIds = new Set(quotesData.quotes.map(q => q.id));
const missingQuotes = hardcodedQuotes.filter(q => !existingQuoteIds.has(q.id));
console.log(`🔍 Found ${missingQuotes.length} missing quotes from hardcoded data`);
if (missingQuotes.length > 0) {
// Convert hardcoded quotes to proper format
const formattedQuotes = missingQuotes.map(quote => ({
...quote,
language: 'de',
dateAdded: new Date().toISOString().split('T')[0]
}));
// Add missing quotes
quotesData.quotes.push(...formattedQuotes);
// Save updated quotes file
fs.writeFileSync(quotesPath, JSON.stringify(quotesData, null, 2), 'utf8');
console.log(`✅ Added ${missingQuotes.length} quotes to German JSON`);
}
// Check authors
const existingAuthorIds = new Set(authorsData.authors.map(a => a.id));
const missingAuthors = hardcodedAuthors.filter(a => !existingAuthorIds.has(a.id));
console.log(`🔍 Found ${missingAuthors.length} missing authors from hardcoded data`);
if (missingAuthors.length > 0) {
authorsData.authors.push(...missingAuthors);
fs.writeFileSync(authorsPath, JSON.stringify(authorsData, null, 2), 'utf8');
console.log(`✅ Added ${missingAuthors.length} authors to German JSON`);
}
console.log(`📊 Final stats:`);
console.log(` German quotes: ${quotesData.quotes.length}`);
console.log(` German authors: ${authorsData.authors.length}`);
const quotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const authorsPath = path.join(__dirname, '../content/data/de/authors.json');
// Load existing JSON data
const quotesData = JSON.parse(fs.readFileSync(quotesPath, 'utf8'));
const authorsData = JSON.parse(fs.readFileSync(authorsPath, 'utf8'));
console.log(`📚 Current German quotes: ${quotesData.quotes.length}`);
console.log(`👥 Current German authors: ${authorsData.authors.length}`);
// Check if hardcoded quotes are missing from JSON
const existingQuoteIds = new Set(quotesData.quotes.map((q) => q.id));
const missingQuotes = hardcodedQuotes.filter((q) => !existingQuoteIds.has(q.id));
console.log(`🔍 Found ${missingQuotes.length} missing quotes from hardcoded data`);
if (missingQuotes.length > 0) {
// Convert hardcoded quotes to proper format
const formattedQuotes = missingQuotes.map((quote) => ({
...quote,
language: 'de',
dateAdded: new Date().toISOString().split('T')[0],
}));
// Add missing quotes
quotesData.quotes.push(...formattedQuotes);
// Save updated quotes file
fs.writeFileSync(quotesPath, JSON.stringify(quotesData, null, 2), 'utf8');
console.log(`✅ Added ${missingQuotes.length} quotes to German JSON`);
}
// Check authors
const existingAuthorIds = new Set(authorsData.authors.map((a) => a.id));
const missingAuthors = hardcodedAuthors.filter((a) => !existingAuthorIds.has(a.id));
console.log(`🔍 Found ${missingAuthors.length} missing authors from hardcoded data`);
if (missingAuthors.length > 0) {
authorsData.authors.push(...missingAuthors);
fs.writeFileSync(authorsPath, JSON.stringify(authorsData, null, 2), 'utf8');
console.log(`✅ Added ${missingAuthors.length} authors to German JSON`);
}
console.log(`📊 Final stats:`);
console.log(` German quotes: ${quotesData.quotes.length}`);
console.log(` German authors: ${authorsData.authors.length}`);
}
// For now, let's just analyze the data without merging
function analyzeData() {
const quotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const authorsPath = path.join(__dirname, '../content/data/de/authors.json');
try {
const quotesData = JSON.parse(fs.readFileSync(quotesPath, 'utf8'));
const authorsData = JSON.parse(fs.readFileSync(authorsPath, 'utf8'));
console.log(`📚 Current German quotes: ${quotesData.quotes.length}`);
console.log(`👥 Current German authors: ${authorsData.authors.length}`);
// Sample some quote IDs to see the format
console.log(`\n🔍 Sample quote IDs:`);
quotesData.quotes.slice(0, 10).forEach(q => {
console.log(` ${q.id}: ${q.text.substring(0, 50)}...`);
});
console.log(`\n👤 Sample author IDs:`);
authorsData.authors.slice(0, 10).forEach(a => {
console.log(` ${a.id}: ${a.name}`);
});
} catch (error) {
console.error('Error analyzing data:', error);
}
const quotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const authorsPath = path.join(__dirname, '../content/data/de/authors.json');
try {
const quotesData = JSON.parse(fs.readFileSync(quotesPath, 'utf8'));
const authorsData = JSON.parse(fs.readFileSync(authorsPath, 'utf8'));
console.log(`📚 Current German quotes: ${quotesData.quotes.length}`);
console.log(`👥 Current German authors: ${authorsData.authors.length}`);
// Sample some quote IDs to see the format
console.log(`\n🔍 Sample quote IDs:`);
quotesData.quotes.slice(0, 10).forEach((q) => {
console.log(` ${q.id}: ${q.text.substring(0, 50)}...`);
});
console.log(`\n👤 Sample author IDs:`);
authorsData.authors.slice(0, 10).forEach((a) => {
console.log(` ${a.id}: ${a.name}`);
});
} catch (error) {
console.error('Error analyzing data:', error);
}
}
if (require.main === module) {
analyzeData();
analyzeData();
}
module.exports = { mergeQuotes, analyzeData };
module.exports = { mergeQuotes, analyzeData };

View file

@ -10,66 +10,66 @@ const existingAuthorsData = JSON.parse(fs.readFileSync(authorsJsonPath, 'utf8'))
// Hardcoded quotes to add (q101-q150)
const newQuotes = [
{
id: 'q101',
text: 'Der Unterschied zwischen Vergangenheit, Gegenwart und Zukunft ist nur eine Illusion, wenn auch eine hartnäckige.',
authorId: 'einstein-albert',
language: 'de',
categories: ['time', 'philosophy'],
tags: ['illusion', 'physics'],
dateAdded: new Date().toISOString().split('T')[0],
likes: 1890,
},
{
id: 'q102',
text: 'Ich denke niemals an die Zukunft. Sie kommt früh genug.',
authorId: 'einstein-albert',
language: 'de',
categories: ['time', 'wisdom'],
tags: ['future', 'present'],
dateAdded: new Date().toISOString().split('T')[0],
likes: 1450,
},
// Add all other new quotes here...
// (I'll add just a few examples for brevity)
{
id: 'q101',
text: 'Der Unterschied zwischen Vergangenheit, Gegenwart und Zukunft ist nur eine Illusion, wenn auch eine hartnäckige.',
authorId: 'einstein-albert',
language: 'de',
categories: ['time', 'philosophy'],
tags: ['illusion', 'physics'],
dateAdded: new Date().toISOString().split('T')[0],
likes: 1890,
},
{
id: 'q102',
text: 'Ich denke niemals an die Zukunft. Sie kommt früh genug.',
authorId: 'einstein-albert',
language: 'de',
categories: ['time', 'wisdom'],
tags: ['future', 'present'],
dateAdded: new Date().toISOString().split('T')[0],
likes: 1450,
},
// Add all other new quotes here...
// (I'll add just a few examples for brevity)
];
// Check for existing quote IDs and update if needed
const existingIds = new Set(existingQuotesData.quotes.map(q => q.id));
const existingIds = new Set(existingQuotesData.quotes.map((q) => q.id));
const quotesToAdd = [];
newQuotes.forEach(quote => {
if (!existingIds.has(quote.id)) {
quotesToAdd.push(quote);
} else {
console.log(`Quote ${quote.id} already exists, skipping...`);
}
newQuotes.forEach((quote) => {
if (!existingIds.has(quote.id)) {
quotesToAdd.push(quote);
} else {
console.log(`Quote ${quote.id} already exists, skipping...`);
}
});
// Add new quotes to existing data
if (quotesToAdd.length > 0) {
existingQuotesData.quotes.push(...quotesToAdd);
console.log(`Added ${quotesToAdd.length} new quotes`);
// Sort by ID for consistency
existingQuotesData.quotes.sort((a, b) => {
const numA = parseInt(a.id.replace(/\D/g, ''));
const numB = parseInt(b.id.replace(/\D/g, ''));
return numA - numB;
});
// Update metadata
existingQuotesData.metadata = {
...existingQuotesData.metadata,
totalQuotes: existingQuotesData.quotes.length,
lastUpdated: new Date().toISOString()
};
// Write back to file
fs.writeFileSync(quotesJsonPath, JSON.stringify(existingQuotesData, null, 2));
console.log(`Updated ${quotesJsonPath} with ${existingQuotesData.quotes.length} total quotes`);
existingQuotesData.quotes.push(...quotesToAdd);
console.log(`Added ${quotesToAdd.length} new quotes`);
// Sort by ID for consistency
existingQuotesData.quotes.sort((a, b) => {
const numA = parseInt(a.id.replace(/\D/g, ''));
const numB = parseInt(b.id.replace(/\D/g, ''));
return numA - numB;
});
// Update metadata
existingQuotesData.metadata = {
...existingQuotesData.metadata,
totalQuotes: existingQuotesData.quotes.length,
lastUpdated: new Date().toISOString(),
};
// Write back to file
fs.writeFileSync(quotesJsonPath, JSON.stringify(existingQuotesData, null, 2));
console.log(`Updated ${quotesJsonPath} with ${existingQuotesData.quotes.length} total quotes`);
}
console.log('Merge complete!');
console.log(`Total quotes: ${existingQuotesData.quotes.length}`);
console.log(`Total authors: ${existingAuthorsData.authors.length}`);
console.log(`Total authors: ${existingAuthorsData.authors.length}`);

View file

@ -9,113 +9,114 @@ const path = require('path');
// List of duplicate IDs to remove (keeping the first occurrence)
const duplicatesToRemove = [
'q-151', // Duplicate of q-050 (Churchill quote about success)
'q-155', // Duplicate of q-090 (Churchill - If you're going through hell)
'q-160', // Duplicate of q-068 (Churchill - The art is to get up once more)
'q-163', // Duplicate of q-063 (Aristoteles - We can't change the wind)
'q-166', // Duplicate of q-007 (Confucius - The journey is the destination)
'q-167', // Duplicate of q-008 (Brecht - Who fights may lose)
'q-168', // Duplicate of q-053 (Chinese proverb - Best time to plant a tree)
'q-169', // Duplicate of q-076 (Hesse - Must try the impossible)
'q-170', // Duplicate of q-089 (Marcus Aurelius - Happiness depends on thoughts)
'q-171', // Duplicate of q-136 (Victor Hugo - Nothing is more powerful than an idea)
'q-173', // Duplicate of q-035 (Twain - Give every day the chance)
'q-174', // Duplicate of q-028 (Gandhi - The future depends on what we do today)
'q-175', // Duplicate of q-048 (Henry Ford - Who always does what he can)
'q-178', // Duplicate of q-147 (Adler - Greatest danger is being too cautious)
'q-187', // Duplicate of q-100 (Swindoll - Life is 10% what happens)
'q-188', // Duplicate of q-001 (Einstein - Imagination is more important)
'q-189', // Duplicate of q-049 (Lasorda - Difference between impossible)
'q-193', // Duplicate of q-082 (Eleanor Roosevelt - No one can make you feel inferior)
'q-194', // Duplicate of q-112 (Tracy/Brown - The only limits)
'q-197', // Duplicate of q-018 (Bruckner - Who wants to build high towers)
'q-199', // Duplicate of q-045 (Democritus - Courage at beginning)
'q-226', // Duplicate of q-016 (Nietzsche - Become who you are)
'q-252' // Duplicate of q-089 (Marcus Aurelius - happiness of your life)
'q-151', // Duplicate of q-050 (Churchill quote about success)
'q-155', // Duplicate of q-090 (Churchill - If you're going through hell)
'q-160', // Duplicate of q-068 (Churchill - The art is to get up once more)
'q-163', // Duplicate of q-063 (Aristoteles - We can't change the wind)
'q-166', // Duplicate of q-007 (Confucius - The journey is the destination)
'q-167', // Duplicate of q-008 (Brecht - Who fights may lose)
'q-168', // Duplicate of q-053 (Chinese proverb - Best time to plant a tree)
'q-169', // Duplicate of q-076 (Hesse - Must try the impossible)
'q-170', // Duplicate of q-089 (Marcus Aurelius - Happiness depends on thoughts)
'q-171', // Duplicate of q-136 (Victor Hugo - Nothing is more powerful than an idea)
'q-173', // Duplicate of q-035 (Twain - Give every day the chance)
'q-174', // Duplicate of q-028 (Gandhi - The future depends on what we do today)
'q-175', // Duplicate of q-048 (Henry Ford - Who always does what he can)
'q-178', // Duplicate of q-147 (Adler - Greatest danger is being too cautious)
'q-187', // Duplicate of q-100 (Swindoll - Life is 10% what happens)
'q-188', // Duplicate of q-001 (Einstein - Imagination is more important)
'q-189', // Duplicate of q-049 (Lasorda - Difference between impossible)
'q-193', // Duplicate of q-082 (Eleanor Roosevelt - No one can make you feel inferior)
'q-194', // Duplicate of q-112 (Tracy/Brown - The only limits)
'q-197', // Duplicate of q-018 (Bruckner - Who wants to build high towers)
'q-199', // Duplicate of q-045 (Democritus - Courage at beginning)
'q-226', // Duplicate of q-016 (Nietzsche - Become who you are)
'q-252', // Duplicate of q-089 (Marcus Aurelius - happiness of your life)
];
function removeDuplicates() {
console.log('🧹 Removing duplicate quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Before removal:`);
console.log(` German quotes: ${deQuotes.quotes.length}`);
console.log(` English quotes: ${enQuotes.quotes.length}`);
console.log(` Duplicates to remove: ${duplicatesToRemove.length}`);
console.log('');
// Remove duplicates from German quotes
const deFiltered = deQuotes.quotes.filter(quote =>
!duplicatesToRemove.includes(quote.id)
);
// Remove duplicates from English quotes
const enFiltered = enQuotes.quotes.filter(quote =>
!duplicatesToRemove.includes(quote.id)
);
// Update the data structures
deQuotes.quotes = deFiltered;
enQuotes.quotes = enFiltered;
// Save cleaned files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Successfully removed duplicates!`);
console.log(`📊 After removal:`);
console.log(` German quotes: ${deQuotes.quotes.length} (removed ${duplicatesToRemove.length})`);
console.log(` English quotes: ${enQuotes.quotes.length} (removed ${duplicatesToRemove.length})`);
console.log('');
// Verify no duplicates remain
console.log('🔍 Verifying no duplicates remain...');
const deTexts = new Set();
let remainingDuplicates = 0;
deQuotes.quotes.forEach(quote => {
const normalizedText = quote.text.toLowerCase().trim();
if (deTexts.has(normalizedText)) {
remainingDuplicates++;
console.log(` ⚠️ Still duplicate: ${quote.text.substring(0, 50)}...`);
} else {
deTexts.set(normalizedText);
}
});
if (remainingDuplicates === 0) {
console.log('✅ All duplicates have been removed!');
} else {
console.log(`⚠️ ${remainingDuplicates} duplicates still remain`);
}
// Final statistics
console.log('\n📈 Final statistics:');
console.log(` • Total unique quotes: ${deQuotes.quotes.length}`);
console.log(` • Unique texts: ${deTexts.size}`);
console.log(` • IDs match between languages: ${deQuotes.quotes.length === enQuotes.quotes.length ? '✅ Yes' : '❌ No'}`);
return {
removed: duplicatesToRemove.length,
finalCount: deQuotes.quotes.length,
uniqueTexts: deTexts.size
};
} catch (error) {
console.error('❌ Failed to remove duplicates:', error);
return null;
}
console.log('🧹 Removing duplicate quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Before removal:`);
console.log(` German quotes: ${deQuotes.quotes.length}`);
console.log(` English quotes: ${enQuotes.quotes.length}`);
console.log(` Duplicates to remove: ${duplicatesToRemove.length}`);
console.log('');
// Remove duplicates from German quotes
const deFiltered = deQuotes.quotes.filter((quote) => !duplicatesToRemove.includes(quote.id));
// Remove duplicates from English quotes
const enFiltered = enQuotes.quotes.filter((quote) => !duplicatesToRemove.includes(quote.id));
// Update the data structures
deQuotes.quotes = deFiltered;
enQuotes.quotes = enFiltered;
// Save cleaned files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Successfully removed duplicates!`);
console.log(`📊 After removal:`);
console.log(
` German quotes: ${deQuotes.quotes.length} (removed ${duplicatesToRemove.length})`
);
console.log(
` English quotes: ${enQuotes.quotes.length} (removed ${duplicatesToRemove.length})`
);
console.log('');
// Verify no duplicates remain
console.log('🔍 Verifying no duplicates remain...');
const deTexts = new Set();
let remainingDuplicates = 0;
deQuotes.quotes.forEach((quote) => {
const normalizedText = quote.text.toLowerCase().trim();
if (deTexts.has(normalizedText)) {
remainingDuplicates++;
console.log(` ⚠️ Still duplicate: ${quote.text.substring(0, 50)}...`);
} else {
deTexts.set(normalizedText);
}
});
if (remainingDuplicates === 0) {
console.log('✅ All duplicates have been removed!');
} else {
console.log(`⚠️ ${remainingDuplicates} duplicates still remain`);
}
// Final statistics
console.log('\n📈 Final statistics:');
console.log(` • Total unique quotes: ${deQuotes.quotes.length}`);
console.log(` • Unique texts: ${deTexts.size}`);
console.log(
` • IDs match between languages: ${deQuotes.quotes.length === enQuotes.quotes.length ? '✅ Yes' : '❌ No'}`
);
return {
removed: duplicatesToRemove.length,
finalCount: deQuotes.quotes.length,
uniqueTexts: deTexts.size,
};
} catch (error) {
console.error('❌ Failed to remove duplicates:', error);
return null;
}
}
if (require.main === module) {
removeDuplicates();
removeDuplicates();
}
module.exports = { removeDuplicates };
module.exports = { removeDuplicates };

View file

@ -8,50 +8,49 @@ const fs = require('fs');
const path = require('path');
function removeFinalDuplicate() {
console.log('🧹 Removing final duplicate Einstein quote...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Before removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Remove q-267 (duplicate of q-133)
const duplicateId = 'q-267';
// Check what we're removing
const duplicateQuote = deQuotes.quotes.find(q => q.id === duplicateId);
if (duplicateQuote) {
console.log(` Removing [${duplicateId}]: "${duplicateQuote.text.substring(0, 60)}..."`);
}
// Filter out the duplicate
deQuotes.quotes = deQuotes.quotes.filter(q => q.id !== duplicateId);
enQuotes.quotes = enQuotes.quotes.filter(q => q.id !== duplicateId);
// Save cleaned files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Removed duplicate!`);
console.log(`📊 After removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count Einstein quotes
const einsteinQuotes = deQuotes.quotes.filter(q => q.authorId === 'einstein-albert');
console.log(` Einstein quotes: ${einsteinQuotes.length}`);
} catch (error) {
console.error('❌ Failed to remove duplicate:', error);
}
console.log('🧹 Removing final duplicate Einstein quote...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Before removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Remove q-267 (duplicate of q-133)
const duplicateId = 'q-267';
// Check what we're removing
const duplicateQuote = deQuotes.quotes.find((q) => q.id === duplicateId);
if (duplicateQuote) {
console.log(` Removing [${duplicateId}]: "${duplicateQuote.text.substring(0, 60)}..."`);
}
// Filter out the duplicate
deQuotes.quotes = deQuotes.quotes.filter((q) => q.id !== duplicateId);
enQuotes.quotes = enQuotes.quotes.filter((q) => q.id !== duplicateId);
// Save cleaned files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Removed duplicate!`);
console.log(`📊 After removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count Einstein quotes
const einsteinQuotes = deQuotes.quotes.filter((q) => q.authorId === 'einstein-albert');
console.log(` Einstein quotes: ${einsteinQuotes.length}`);
} catch (error) {
console.error('❌ Failed to remove duplicate:', error);
}
}
if (require.main === module) {
removeFinalDuplicate();
removeFinalDuplicate();
}
module.exports = { removeFinalDuplicate };
module.exports = { removeFinalDuplicate };

View file

@ -8,54 +8,69 @@ const fs = require('fs');
const path = require('path');
function removeFinalDuplicates() {
console.log('🧹 Removing duplicate Einstein quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
// IDs to remove (all the duplicates from the last Einstein addition)
const duplicatesToRemove = [
'q-274', 'q-275', 'q-276', 'q-277', 'q-278',
'q-279', 'q-280', 'q-281', 'q-282', 'q-283',
'q-284', 'q-285', 'q-286', 'q-287', 'q-288',
'q-289', 'q-290', 'q-291', 'q-292', 'q-293'
];
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Before removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count Einstein quotes before
const einsteinBefore = deQuotes.quotes.filter(q => q.authorId === 'einstein-albert').length;
console.log(` Einstein quotes before: ${einsteinBefore}`);
// Filter out the duplicates
deQuotes.quotes = deQuotes.quotes.filter(q => !duplicatesToRemove.includes(q.id));
enQuotes.quotes = enQuotes.quotes.filter(q => !duplicatesToRemove.includes(q.id));
// Save cleaned files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Removed ${duplicatesToRemove.length} duplicate Einstein quotes!`);
console.log(`📊 After removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count Einstein quotes after
const einsteinAfter = deQuotes.quotes.filter(q => q.authorId === 'einstein-albert').length;
console.log(` Einstein quotes after: ${einsteinAfter}`);
console.log(` Einstein remains the top author with ${einsteinAfter} unique quotes!`);
} catch (error) {
console.error('❌ Failed to remove duplicates:', error);
}
console.log('🧹 Removing duplicate Einstein quotes...\n');
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
// IDs to remove (all the duplicates from the last Einstein addition)
const duplicatesToRemove = [
'q-274',
'q-275',
'q-276',
'q-277',
'q-278',
'q-279',
'q-280',
'q-281',
'q-282',
'q-283',
'q-284',
'q-285',
'q-286',
'q-287',
'q-288',
'q-289',
'q-290',
'q-291',
'q-292',
'q-293',
];
try {
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
console.log(`📊 Before removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count Einstein quotes before
const einsteinBefore = deQuotes.quotes.filter((q) => q.authorId === 'einstein-albert').length;
console.log(` Einstein quotes before: ${einsteinBefore}`);
// Filter out the duplicates
deQuotes.quotes = deQuotes.quotes.filter((q) => !duplicatesToRemove.includes(q.id));
enQuotes.quotes = enQuotes.quotes.filter((q) => !duplicatesToRemove.includes(q.id));
// Save cleaned files
fs.writeFileSync(deQuotesPath, JSON.stringify(deQuotes, null, 2), 'utf8');
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`\n✅ Removed ${duplicatesToRemove.length} duplicate Einstein quotes!`);
console.log(`📊 After removal:`);
console.log(` Total quotes: ${deQuotes.quotes.length}`);
// Count Einstein quotes after
const einsteinAfter = deQuotes.quotes.filter((q) => q.authorId === 'einstein-albert').length;
console.log(` Einstein quotes after: ${einsteinAfter}`);
console.log(` Einstein remains the top author with ${einsteinAfter} unique quotes!`);
} catch (error) {
console.error('❌ Failed to remove duplicates:', error);
}
}
if (require.main === module) {
removeFinalDuplicates();
removeFinalDuplicates();
}
module.exports = { removeFinalDuplicates };
module.exports = { removeFinalDuplicates };

View file

@ -9,215 +9,219 @@ const path = require('path');
// Comprehensive translation mapping for quotes 1-200
const fullTranslations = {
'q-001': {
text: 'Imagination is more important than knowledge, because knowledge is limited.',
source: 'Interview with George Sylvester Viereck',
context: 'Einstein emphasized the importance of creativity in scientific research.'
},
'q-002': {
text: 'Two things are infinite: the universe and human stupidity; and I\'m not sure about the universe.',
context: 'One of Einstein\'s most famous humorous observations about humanity.'
},
'q-003': {
text: 'God does not play dice.',
source: 'Letter to Max Born',
context: 'Einstein\'s famous critique of quantum mechanics and his belief in determinism.'
},
'q-004': {
text: 'One sees clearly only with the heart. What is essential is invisible to the eye.',
source: 'The Little Prince',
context: 'From "The Little Prince" - about the importance of emotional understanding.'
},
'q-005': {
text: 'You become responsible, forever, for what you have tamed.',
source: 'The Little Prince',
context: 'About responsibility and deep connections in relationships.'
},
'q-006': {
text: 'We all live under the same sky, but we don\'t all have the same horizon.',
context: 'Adenauer\'s reflection on different perspectives despite shared humanity.'
},
'q-007': {
text: 'The journey is the destination.',
context: 'Confucian philosophy emphasizing process over outcome.'
},
'q-008': {
text: 'Those who fight may lose. Those who don\'t fight have already lost.',
context: 'Brecht\'s call for action and courage in the face of adversity.'
},
'q-009': {
text: 'What does not kill me makes me stronger.',
context: 'Nietzsche\'s philosophy about resilience and growth through adversity.'
},
'q-010': {
text: 'The limits of my language are the limits of my world.',
context: 'Wittgenstein\'s famous proposition about language and reality.'
},
'q-011': {
text: 'I think, therefore I am.',
context: 'Descartes\' foundational principle of philosophy and existence.'
},
'q-012': {
text: 'Even from stones that are placed in your way, you can build something beautiful.',
context: 'Goethe\'s wisdom about turning obstacles into opportunities.'
},
'q-013': {
text: 'All our knowledge begins with the senses, proceeds then to understanding, and ends with reason.',
context: 'Kant\'s epistemological framework about how we acquire knowledge.'
},
'q-014': {
text: 'The world as will and representation.',
context: 'Schopenhauer\'s central philosophical concept about reality.'
},
'q-015': {
text: 'Freedom is the only thing worth possessing.',
context: 'Thoreau\'s emphasis on personal liberty and independence.'
},
'q-016': {
text: 'The cave you fear to enter holds the treasure you seek.',
context: 'Campbell\'s insight about facing our fears to find growth.'
},
'q-017': {
text: 'Beauty is truth, truth beauty - that is all you know on earth, and all you need to know.',
context: 'Keats\' romantic ideal connecting aesthetic and philosophical truth.'
},
'q-018': {
text: 'The unexamined life is not worth living.',
context: 'Socrates\' defense of philosophical inquiry and self-reflection.'
},
'q-019': {
text: 'We are what we repeatedly do. Excellence, then, is not an act, but a habit.',
context: 'Aristotelian concept of virtue ethics and character development.'
},
'q-020': {
text: 'The way to get started is to quit talking and begin doing.',
context: 'Disney\'s practical wisdom about turning ideas into action.'
},
'q-021': {
text: 'In the middle of difficulty lies opportunity.',
context: 'Einstein\'s perspective on finding possibilities within challenges.'
},
'q-022': {
text: 'Success is not final, failure is not fatal: it is the courage to continue that counts.',
context: 'Churchill\'s wisdom about perseverance and resilience.'
},
'q-023': {
text: 'The only way to do great work is to love what you do.',
context: 'Jobs\' philosophy about passion and professional fulfillment.'
},
'q-024': {
text: 'Life is what happens to you while you\'re busy making other plans.',
context: 'Lennon\'s observation about life\'s unpredictability.'
},
'q-025': {
text: 'Be yourself; everyone else is already taken.',
context: 'Wilde\'s witty advice about authenticity and individuality.'
},
'q-026': {
text: 'Yesterday is history, tomorrow is a mystery, today is a gift.',
context: 'Wisdom about living in the present moment.'
},
'q-027': {
text: 'It is during our darkest moments that we must focus to see the light.',
context: 'Aristotle\'s guidance about finding hope in difficult times.'
},
'q-028': {
text: 'The future belongs to those who believe in the beauty of their dreams.',
context: 'Roosevelt\'s inspiring words about hope and ambition.'
},
'q-029': {
text: 'It is never too late to be what you might have been.',
context: 'Eliot\'s encouragement about personal transformation at any age.'
},
'q-030': {
text: 'Whether you think you can or you think you can\'t, you\'re right.',
context: 'Ford\'s insight about the power of mindset and belief.'
}
'q-001': {
text: 'Imagination is more important than knowledge, because knowledge is limited.',
source: 'Interview with George Sylvester Viereck',
context: 'Einstein emphasized the importance of creativity in scientific research.',
},
'q-002': {
text: "Two things are infinite: the universe and human stupidity; and I'm not sure about the universe.",
context: "One of Einstein's most famous humorous observations about humanity.",
},
'q-003': {
text: 'God does not play dice.',
source: 'Letter to Max Born',
context: "Einstein's famous critique of quantum mechanics and his belief in determinism.",
},
'q-004': {
text: 'One sees clearly only with the heart. What is essential is invisible to the eye.',
source: 'The Little Prince',
context: 'From "The Little Prince" - about the importance of emotional understanding.',
},
'q-005': {
text: 'You become responsible, forever, for what you have tamed.',
source: 'The Little Prince',
context: 'About responsibility and deep connections in relationships.',
},
'q-006': {
text: "We all live under the same sky, but we don't all have the same horizon.",
context: "Adenauer's reflection on different perspectives despite shared humanity.",
},
'q-007': {
text: 'The journey is the destination.',
context: 'Confucian philosophy emphasizing process over outcome.',
},
'q-008': {
text: "Those who fight may lose. Those who don't fight have already lost.",
context: "Brecht's call for action and courage in the face of adversity.",
},
'q-009': {
text: 'What does not kill me makes me stronger.',
context: "Nietzsche's philosophy about resilience and growth through adversity.",
},
'q-010': {
text: 'The limits of my language are the limits of my world.',
context: "Wittgenstein's famous proposition about language and reality.",
},
'q-011': {
text: 'I think, therefore I am.',
context: "Descartes' foundational principle of philosophy and existence.",
},
'q-012': {
text: 'Even from stones that are placed in your way, you can build something beautiful.',
context: "Goethe's wisdom about turning obstacles into opportunities.",
},
'q-013': {
text: 'All our knowledge begins with the senses, proceeds then to understanding, and ends with reason.',
context: "Kant's epistemological framework about how we acquire knowledge.",
},
'q-014': {
text: 'The world as will and representation.',
context: "Schopenhauer's central philosophical concept about reality.",
},
'q-015': {
text: 'Freedom is the only thing worth possessing.',
context: "Thoreau's emphasis on personal liberty and independence.",
},
'q-016': {
text: 'The cave you fear to enter holds the treasure you seek.',
context: "Campbell's insight about facing our fears to find growth.",
},
'q-017': {
text: 'Beauty is truth, truth beauty - that is all you know on earth, and all you need to know.',
context: "Keats' romantic ideal connecting aesthetic and philosophical truth.",
},
'q-018': {
text: 'The unexamined life is not worth living.',
context: "Socrates' defense of philosophical inquiry and self-reflection.",
},
'q-019': {
text: 'We are what we repeatedly do. Excellence, then, is not an act, but a habit.',
context: 'Aristotelian concept of virtue ethics and character development.',
},
'q-020': {
text: 'The way to get started is to quit talking and begin doing.',
context: "Disney's practical wisdom about turning ideas into action.",
},
'q-021': {
text: 'In the middle of difficulty lies opportunity.',
context: "Einstein's perspective on finding possibilities within challenges.",
},
'q-022': {
text: 'Success is not final, failure is not fatal: it is the courage to continue that counts.',
context: "Churchill's wisdom about perseverance and resilience.",
},
'q-023': {
text: 'The only way to do great work is to love what you do.',
context: "Jobs' philosophy about passion and professional fulfillment.",
},
'q-024': {
text: "Life is what happens to you while you're busy making other plans.",
context: "Lennon's observation about life's unpredictability.",
},
'q-025': {
text: 'Be yourself; everyone else is already taken.',
context: "Wilde's witty advice about authenticity and individuality.",
},
'q-026': {
text: 'Yesterday is history, tomorrow is a mystery, today is a gift.',
context: 'Wisdom about living in the present moment.',
},
'q-027': {
text: 'It is during our darkest moments that we must focus to see the light.',
context: "Aristotle's guidance about finding hope in difficult times.",
},
'q-028': {
text: 'The future belongs to those who believe in the beauty of their dreams.',
context: "Roosevelt's inspiring words about hope and ambition.",
},
'q-029': {
text: 'It is never too late to be what you might have been.',
context: "Eliot's encouragement about personal transformation at any age.",
},
'q-030': {
text: "Whether you think you can or you think you can't, you're right.",
context: "Ford's insight about the power of mindset and belief.",
},
};
// Common source translations
const sourceTranslations = {
'Der kleine Prinz': 'The Little Prince',
'Brief an Max Born': 'Letter to Max Born',
'Interview mit George Sylvester Viereck': 'Interview with George Sylvester Viereck',
'Also sprach Zarathustra': 'Thus Spoke Zarathustra',
'Die fröhliche Wissenschaft': 'The Gay Science',
'Kritik der reinen Vernunft': 'Critique of Pure Reason',
'Die Welt als Wille und Vorstellung': 'The World as Will and Representation',
'Über die Grenzen der Sprache': 'On the Limits of Language'
'Der kleine Prinz': 'The Little Prince',
'Brief an Max Born': 'Letter to Max Born',
'Interview mit George Sylvester Viereck': 'Interview with George Sylvester Viereck',
'Also sprach Zarathustra': 'Thus Spoke Zarathustra',
'Die fröhliche Wissenschaft': 'The Gay Science',
'Kritik der reinen Vernunft': 'Critique of Pure Reason',
'Die Welt als Wille und Vorstellung': 'The World as Will and Representation',
'Über die Grenzen der Sprache': 'On the Limits of Language',
};
// Common context translations
const contextTranslations = {
'Einstein betonte die Bedeutung der Kreativität in der wissenschaftlichen Forschung.': 'Einstein emphasized the importance of creativity in scientific research.',
'Aus "Der kleine Prinz" - über die Bedeutung des emotionalen Verstehens.': 'From "The Little Prince" - about the importance of emotional understanding.',
'Über Verantwortung und tiefe Verbindungen in Beziehungen.': 'About responsibility and deep connections in relationships.',
'Nietzsches Philosophie über Widerstandsfähigkeit und Wachstum durch Widrigkeiten.': 'Nietzsche\'s philosophy about resilience and growth through adversity.',
'Goethes Weisheit über das Verwandeln von Hindernissen in Möglichkeiten.': 'Goethe\'s wisdom about turning obstacles into opportunities.'
'Einstein betonte die Bedeutung der Kreativität in der wissenschaftlichen Forschung.':
'Einstein emphasized the importance of creativity in scientific research.',
'Aus "Der kleine Prinz" - über die Bedeutung des emotionalen Verstehens.':
'From "The Little Prince" - about the importance of emotional understanding.',
'Über Verantwortung und tiefe Verbindungen in Beziehungen.':
'About responsibility and deep connections in relationships.',
'Nietzsches Philosophie über Widerstandsfähigkeit und Wachstum durch Widrigkeiten.':
"Nietzsche's philosophy about resilience and growth through adversity.",
'Goethes Weisheit über das Verwandeln von Hindernissen in Möglichkeiten.':
"Goethe's wisdom about turning obstacles into opportunities.",
};
async function translateBatch(startId, endId, batchName) {
console.log(`\n📚 Starting ${batchName} (${startId} to ${endId})...`);
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Process each quote in the range
enQuotes.quotes.forEach((quote, index) => {
if (quote.id >= startId && quote.id <= endId) {
const translation = fullTranslations[quote.id];
if (translation) {
// Apply translation
quote.text = translation.text;
if (translation.source) quote.source = translation.source;
if (translation.context) quote.context = translation.context;
translatedCount++;
} else {
// Apply common translations for sources and contexts
if (quote.source && sourceTranslations[quote.source]) {
quote.source = sourceTranslations[quote.source];
}
if (quote.context && contextTranslations[quote.context]) {
quote.context = contextTranslations[quote.context];
}
}
}
});
// Save updated English quotes
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`${batchName} complete: ${translatedCount} quotes translated`);
return translatedCount;
console.log(`\n📚 Starting ${batchName} (${startId} to ${endId})...`);
const deQuotesPath = path.join(__dirname, '../content/data/de/quotes.json');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
// Load current data
const deQuotes = JSON.parse(fs.readFileSync(deQuotesPath, 'utf8'));
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Process each quote in the range
enQuotes.quotes.forEach((quote, index) => {
if (quote.id >= startId && quote.id <= endId) {
const translation = fullTranslations[quote.id];
if (translation) {
// Apply translation
quote.text = translation.text;
if (translation.source) quote.source = translation.source;
if (translation.context) quote.context = translation.context;
translatedCount++;
} else {
// Apply common translations for sources and contexts
if (quote.source && sourceTranslations[quote.source]) {
quote.source = sourceTranslations[quote.source];
}
if (quote.context && contextTranslations[quote.context]) {
quote.context = contextTranslations[quote.context];
}
}
}
});
// Save updated English quotes
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`${batchName} complete: ${translatedCount} quotes translated`);
return translatedCount;
}
async function translateAllQuotes() {
console.log('🌍 Starting comprehensive translation of all 200 quotes...\n');
try {
let totalTranslated = 0;
// Translate in batches
totalTranslated += await translateBatch('q-001', 'q-030', 'Batch 1 (1-30)');
// For now, let's start with the first 30 quotes that have full translations
console.log(`\n🎉 Translation phase 1 complete!`);
console.log(`📊 Total quotes fully translated: ${totalTranslated}`);
console.log('📝 Next: Will continue with remaining quotes in subsequent batches');
} catch (error) {
console.error('❌ Translation failed:', error);
}
console.log('🌍 Starting comprehensive translation of all 200 quotes...\n');
try {
let totalTranslated = 0;
// Translate in batches
totalTranslated += await translateBatch('q-001', 'q-030', 'Batch 1 (1-30)');
// For now, let's start with the first 30 quotes that have full translations
console.log(`\n🎉 Translation phase 1 complete!`);
console.log(`📊 Total quotes fully translated: ${totalTranslated}`);
console.log('📝 Next: Will continue with remaining quotes in subsequent batches');
} catch (error) {
console.error('❌ Translation failed:', error);
}
}
if (require.main === module) {
translateAllQuotes();
translateAllQuotes();
}
module.exports = { translateAllQuotes, translateBatch };
module.exports = { translateAllQuotes, translateBatch };

View file

@ -9,163 +9,162 @@ const path = require('path');
// Translations for quotes q-031 to q-060
const batch2Translations = {
'q-031': {
text: 'Nothing on earth is so powerful as an idea whose time has come.',
context: 'Hugo\'s insight about the unstoppable force of timely ideas.'
},
'q-032': {
text: 'The pen is mightier than the sword.',
context: 'Bulwer-Lytton\'s famous assertion about the power of words over violence.'
},
'q-033': {
text: 'To be, or not to be, that is the question.',
context: 'Hamlet\'s existential soliloquy from Shakespeare\'s masterpiece.'
},
'q-034': {
text: 'All the world\'s a stage, and all the men and women merely players.',
context: 'Shakespeare\'s metaphor for life from "As You Like It".'
},
'q-035': {
text: 'Knowledge is power.',
context: 'Bacon\'s fundamental principle about the value of learning.'
},
'q-036': {
text: 'I have a dream.',
context: 'Martin Luther King Jr.\'s iconic speech about civil rights and equality.'
},
'q-037': {
text: 'Give me liberty, or give me death!',
context: 'Patrick Henry\'s passionate plea for American independence.'
},
'q-038': {
text: 'The only thing we have to fear is fear itself.',
context: 'FDR\'s reassuring words during the Great Depression.'
},
'q-039': {
text: 'Ask not what your country can do for you—ask what you can do for your country.',
context: 'JFK\'s inaugural call for civic duty and service.'
},
'q-040': {
text: 'Darkness cannot drive out darkness; only light can do that.',
context: 'Martin Luther King Jr.\'s wisdom about overcoming hatred with love.'
},
'q-041': {
text: 'The best time to plant a tree was 20 years ago. The second best time is now.',
context: 'Chinese proverb about taking action despite missed opportunities.'
},
'q-042': {
text: 'A journey of a thousand miles begins with a single step.',
context: 'Lao Tzu\'s wisdom about starting any great endeavor.'
},
'q-043': {
text: 'When the winds of change blow, some people build walls and others build windmills.',
context: 'Chinese proverb about adapting to change versus resisting it.'
},
'q-044': {
text: 'If you want to go fast, go alone. If you want to go far, go together.',
context: 'African proverb about the balance between speed and sustainability.'
},
'q-045': {
text: 'The master has failed more times than the beginner has even tried.',
context: 'Wisdom about how expertise comes through persistence and failure.'
},
'q-046': {
text: 'What lies behind us and what lies before us are tiny matters compared to what lies within us.',
context: 'Emerson\'s insight about inner strength and potential.'
},
'q-047': {
text: 'The only impossible journey is the one you never begin.',
context: 'Lao Tzu\'s encouragement to start pursuing your goals.'
},
'q-048': {
text: 'In the midst of winter, I found there was, within me, an invincible summer.',
context: 'Camus\' reflection on finding inner strength during dark times.'
},
'q-049': {
text: 'The purpose of our lives is to be happy.',
context: 'The Dalai Lama\'s simple yet profound view of life\'s goal.'
},
'q-050': {
text: 'Life is 10% what happens to you and 90% how you react to it.',
context: 'Swindoll\'s perspective on personal responsibility and attitude.'
},
'q-051': {
text: 'The way to get started is to quit talking and begin doing.',
context: 'Walt Disney\'s practical advice about turning ideas into action.'
},
'q-052': {
text: 'If opportunity doesn\'t knock, build a door.',
context: 'Milton Berle\'s advice about creating your own chances.'
},
'q-053': {
text: 'You miss 100% of the shots you don\'t take.',
context: 'Wayne Gretzky\'s sports wisdom applicable to all of life.'
},
'q-054': {
text: 'Whether you think you can or you think you can\'t, you\'re right.',
context: 'Henry Ford\'s insight about the power of mindset.'
},
'q-055': {
text: 'I\'ve learned that people will forget what you said, people will forget what you did, but people will never forget how you made them feel.',
context: 'Maya Angelou\'s wisdom about the lasting impact of emotional connections.'
},
'q-056': {
text: 'The only way to do great work is to love what you do.',
context: 'Steve Jobs\' philosophy about passion and professional fulfillment.'
},
'q-057': {
text: 'Innovation distinguishes between a leader and a follower.',
context: 'Steve Jobs\' view on the importance of creative thinking.'
},
'q-058': {
text: 'Your time is limited, don\'t waste it living someone else\'s life.',
context: 'Steve Jobs\' advice about authenticity and personal purpose.'
},
'q-059': {
text: 'The future belongs to those who believe in the beauty of their dreams.',
context: 'Eleanor Roosevelt\'s inspiring words about hope and ambition.'
},
'q-060': {
text: 'It is never too late to be what you might have been.',
context: 'George Eliot\'s encouragement about personal transformation at any age.'
}
'q-031': {
text: 'Nothing on earth is so powerful as an idea whose time has come.',
context: "Hugo's insight about the unstoppable force of timely ideas.",
},
'q-032': {
text: 'The pen is mightier than the sword.',
context: "Bulwer-Lytton's famous assertion about the power of words over violence.",
},
'q-033': {
text: 'To be, or not to be, that is the question.',
context: "Hamlet's existential soliloquy from Shakespeare's masterpiece.",
},
'q-034': {
text: "All the world's a stage, and all the men and women merely players.",
context: 'Shakespeare\'s metaphor for life from "As You Like It".',
},
'q-035': {
text: 'Knowledge is power.',
context: "Bacon's fundamental principle about the value of learning.",
},
'q-036': {
text: 'I have a dream.',
context: "Martin Luther King Jr.'s iconic speech about civil rights and equality.",
},
'q-037': {
text: 'Give me liberty, or give me death!',
context: "Patrick Henry's passionate plea for American independence.",
},
'q-038': {
text: 'The only thing we have to fear is fear itself.',
context: "FDR's reassuring words during the Great Depression.",
},
'q-039': {
text: 'Ask not what your country can do for you—ask what you can do for your country.',
context: "JFK's inaugural call for civic duty and service.",
},
'q-040': {
text: 'Darkness cannot drive out darkness; only light can do that.',
context: "Martin Luther King Jr.'s wisdom about overcoming hatred with love.",
},
'q-041': {
text: 'The best time to plant a tree was 20 years ago. The second best time is now.',
context: 'Chinese proverb about taking action despite missed opportunities.',
},
'q-042': {
text: 'A journey of a thousand miles begins with a single step.',
context: "Lao Tzu's wisdom about starting any great endeavor.",
},
'q-043': {
text: 'When the winds of change blow, some people build walls and others build windmills.',
context: 'Chinese proverb about adapting to change versus resisting it.',
},
'q-044': {
text: 'If you want to go fast, go alone. If you want to go far, go together.',
context: 'African proverb about the balance between speed and sustainability.',
},
'q-045': {
text: 'The master has failed more times than the beginner has even tried.',
context: 'Wisdom about how expertise comes through persistence and failure.',
},
'q-046': {
text: 'What lies behind us and what lies before us are tiny matters compared to what lies within us.',
context: "Emerson's insight about inner strength and potential.",
},
'q-047': {
text: 'The only impossible journey is the one you never begin.',
context: "Lao Tzu's encouragement to start pursuing your goals.",
},
'q-048': {
text: 'In the midst of winter, I found there was, within me, an invincible summer.',
context: "Camus' reflection on finding inner strength during dark times.",
},
'q-049': {
text: 'The purpose of our lives is to be happy.',
context: "The Dalai Lama's simple yet profound view of life's goal.",
},
'q-050': {
text: 'Life is 10% what happens to you and 90% how you react to it.',
context: "Swindoll's perspective on personal responsibility and attitude.",
},
'q-051': {
text: 'The way to get started is to quit talking and begin doing.',
context: "Walt Disney's practical advice about turning ideas into action.",
},
'q-052': {
text: "If opportunity doesn't knock, build a door.",
context: "Milton Berle's advice about creating your own chances.",
},
'q-053': {
text: "You miss 100% of the shots you don't take.",
context: "Wayne Gretzky's sports wisdom applicable to all of life.",
},
'q-054': {
text: "Whether you think you can or you think you can't, you're right.",
context: "Henry Ford's insight about the power of mindset.",
},
'q-055': {
text: "I've learned that people will forget what you said, people will forget what you did, but people will never forget how you made them feel.",
context: "Maya Angelou's wisdom about the lasting impact of emotional connections.",
},
'q-056': {
text: 'The only way to do great work is to love what you do.',
context: "Steve Jobs' philosophy about passion and professional fulfillment.",
},
'q-057': {
text: 'Innovation distinguishes between a leader and a follower.',
context: "Steve Jobs' view on the importance of creative thinking.",
},
'q-058': {
text: "Your time is limited, don't waste it living someone else's life.",
context: "Steve Jobs' advice about authenticity and personal purpose.",
},
'q-059': {
text: 'The future belongs to those who believe in the beauty of their dreams.',
context: "Eleanor Roosevelt's inspiring words about hope and ambition.",
},
'q-060': {
text: 'It is never too late to be what you might have been.',
context: "George Eliot's encouragement about personal transformation at any age.",
},
};
async function translateBatch2() {
console.log('📚 Starting Batch 2 Translation (q-031 to q-060)...\n');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current English quotes
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Apply translations
enQuotes.quotes.forEach((quote) => {
const translation = batch2Translations[quote.id];
if (translation) {
quote.text = translation.text;
if (translation.context) quote.context = translation.context;
if (translation.source) quote.source = translation.source;
translatedCount++;
}
});
// Save updated file
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Batch 2 complete: ${translatedCount} quotes translated`);
console.log(`📊 Quotes q-031 to q-060 are now fully translated!`);
} catch (error) {
console.error('❌ Batch 2 translation failed:', error);
}
console.log('📚 Starting Batch 2 Translation (q-031 to q-060)...\n');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current English quotes
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Apply translations
enQuotes.quotes.forEach((quote) => {
const translation = batch2Translations[quote.id];
if (translation) {
quote.text = translation.text;
if (translation.context) quote.context = translation.context;
if (translation.source) quote.source = translation.source;
translatedCount++;
}
});
// Save updated file
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Batch 2 complete: ${translatedCount} quotes translated`);
console.log(`📊 Quotes q-031 to q-060 are now fully translated!`);
} catch (error) {
console.error('❌ Batch 2 translation failed:', error);
}
}
if (require.main === module) {
translateBatch2();
translateBatch2();
}
module.exports = { translateBatch2 };
module.exports = { translateBatch2 };

View file

@ -9,86 +9,111 @@ const path = require('path');
// Translations for quotes q-061 to q-100
const batch3Translations = {
'q-061': { text: 'Education is the most powerful weapon which you can use to change the world.' },
'q-062': { text: 'The only source of knowledge is experience.' },
'q-063': { text: 'Live as if you were to die tomorrow. Learn as if you were to live forever.' },
'q-064': { text: 'Be the change that you wish to see in the world.' },
'q-065': { text: 'In a gentle way, you can shake the world.' },
'q-066': { text: 'Where there is love there is life.' },
'q-067': { text: 'Happiness is when what you think, what you say, and what you do are in harmony.' },
'q-068': { text: 'The weak can never forgive. Forgiveness is the attribute of the strong.' },
'q-069': { text: 'An eye for an eye only ends up making the whole world blind.' },
'q-070': { text: 'The best way to find yourself is to lose yourself in the service of others.' },
'q-071': { text: 'You must be the change you wish to see in the world.' },
'q-072': { text: 'The future depends on what you do today.' },
'q-073': { text: 'A nation\'s culture resides in the hearts and in the soul of its people.' },
'q-074': { text: 'Glory lies in the attempt to reach one\'s goal and not in reaching it.' },
'q-075': { text: 'Strength does not come from physical capacity. It comes from an indomitable will.' },
'q-076': { text: 'The difference between what we do and what we are capable of doing would suffice to solve most of the world\'s problems.' },
'q-077': { text: 'You can chain me, you can torture me, you can even destroy this body, but you will never imprison my mind.' },
'q-078': { text: 'Nobody can hurt me without my permission.' },
'q-079': { text: 'Hate the sin, love the sinner.' },
'q-080': { text: 'Truth never damages a cause that is just.' },
'q-081': { text: 'The only tyrant I accept in this world is the \'still voice within\'.' },
'q-082': { text: 'My life is my message.' },
'q-083': { text: 'Service which is rendered without joy helps neither the servant nor the served.' },
'q-084': { text: 'Prayer is not asking. It is a longing of the soul.' },
'q-085': { text: 'The good man is the friend of all living things.' },
'q-086': { text: 'First they ignore you, then they laugh at you, then they fight you, then you win.' },
'q-087': { text: 'Earth provides enough to satisfy every man\'s needs, but not every man\'s greed.' },
'q-088': { text: 'The greatness of a nation can be judged by the way its animals are treated.' },
'q-089': { text: 'There is more to life than increasing its speed.' },
'q-090': { text: 'A small body of determined spirits fired by an unquenchable faith in their mission can alter the course of history.' },
'q-091': { text: 'The essence of all religions is one. Only their approaches are different.' },
'q-092': { text: 'Non-violence is a weapon of the strong.' },
'q-093': { text: 'Satisfaction lies in the effort, not in the attainment.' },
'q-094': { text: 'Freedom is not worth having if it does not include the freedom to make mistakes.' },
'q-095': { text: 'To believe in something, and not to live it, is dishonest.' },
'q-096': { text: 'The moment there is suspicion about a person\'s motives, everything he does becomes tainted.' },
'q-097': { text: 'Intolerance is itself a form of violence and an obstacle to the growth of a true democratic spirit.' },
'q-098': { text: 'You may never know what results come of your actions, but if you do nothing, there will be no results.' },
'q-099': { text: 'Even if you are a minority of one, the truth is the truth.' },
'q-100': { text: 'The light that burns twice as bright burns half as long.' }
'q-061': { text: 'Education is the most powerful weapon which you can use to change the world.' },
'q-062': { text: 'The only source of knowledge is experience.' },
'q-063': { text: 'Live as if you were to die tomorrow. Learn as if you were to live forever.' },
'q-064': { text: 'Be the change that you wish to see in the world.' },
'q-065': { text: 'In a gentle way, you can shake the world.' },
'q-066': { text: 'Where there is love there is life.' },
'q-067': {
text: 'Happiness is when what you think, what you say, and what you do are in harmony.',
},
'q-068': { text: 'The weak can never forgive. Forgiveness is the attribute of the strong.' },
'q-069': { text: 'An eye for an eye only ends up making the whole world blind.' },
'q-070': { text: 'The best way to find yourself is to lose yourself in the service of others.' },
'q-071': { text: 'You must be the change you wish to see in the world.' },
'q-072': { text: 'The future depends on what you do today.' },
'q-073': { text: "A nation's culture resides in the hearts and in the soul of its people." },
'q-074': { text: "Glory lies in the attempt to reach one's goal and not in reaching it." },
'q-075': {
text: 'Strength does not come from physical capacity. It comes from an indomitable will.',
},
'q-076': {
text: "The difference between what we do and what we are capable of doing would suffice to solve most of the world's problems.",
},
'q-077': {
text: 'You can chain me, you can torture me, you can even destroy this body, but you will never imprison my mind.',
},
'q-078': { text: 'Nobody can hurt me without my permission.' },
'q-079': { text: 'Hate the sin, love the sinner.' },
'q-080': { text: 'Truth never damages a cause that is just.' },
'q-081': { text: "The only tyrant I accept in this world is the 'still voice within'." },
'q-082': { text: 'My life is my message.' },
'q-083': {
text: 'Service which is rendered without joy helps neither the servant nor the served.',
},
'q-084': { text: 'Prayer is not asking. It is a longing of the soul.' },
'q-085': { text: 'The good man is the friend of all living things.' },
'q-086': {
text: 'First they ignore you, then they laugh at you, then they fight you, then you win.',
},
'q-087': {
text: "Earth provides enough to satisfy every man's needs, but not every man's greed.",
},
'q-088': { text: 'The greatness of a nation can be judged by the way its animals are treated.' },
'q-089': { text: 'There is more to life than increasing its speed.' },
'q-090': {
text: 'A small body of determined spirits fired by an unquenchable faith in their mission can alter the course of history.',
},
'q-091': { text: 'The essence of all religions is one. Only their approaches are different.' },
'q-092': { text: 'Non-violence is a weapon of the strong.' },
'q-093': { text: 'Satisfaction lies in the effort, not in the attainment.' },
'q-094': {
text: 'Freedom is not worth having if it does not include the freedom to make mistakes.',
},
'q-095': { text: 'To believe in something, and not to live it, is dishonest.' },
'q-096': {
text: "The moment there is suspicion about a person's motives, everything he does becomes tainted.",
},
'q-097': {
text: 'Intolerance is itself a form of violence and an obstacle to the growth of a true democratic spirit.',
},
'q-098': {
text: 'You may never know what results come of your actions, but if you do nothing, there will be no results.',
},
'q-099': { text: 'Even if you are a minority of one, the truth is the truth.' },
'q-100': { text: 'The light that burns twice as bright burns half as long.' },
};
async function translateBatch3() {
console.log('📚 Starting Batch 3 Translation (q-061 to q-100)...\n');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current English quotes
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Apply translations
enQuotes.quotes.forEach((quote) => {
const translation = batch3Translations[quote.id];
if (translation) {
quote.text = translation.text;
if (translation.context) quote.context = translation.context;
if (translation.source) quote.source = translation.source;
translatedCount++;
}
});
// Save updated file
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Batch 3 complete: ${translatedCount} quotes translated`);
console.log(`📊 Total translated so far: 60 + ${translatedCount} = ${60 + translatedCount} quotes`);
return translatedCount;
} catch (error) {
console.error('❌ Batch 3 translation failed:', error);
return 0;
}
console.log('📚 Starting Batch 3 Translation (q-061 to q-100)...\n');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current English quotes
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Apply translations
enQuotes.quotes.forEach((quote) => {
const translation = batch3Translations[quote.id];
if (translation) {
quote.text = translation.text;
if (translation.context) quote.context = translation.context;
if (translation.source) quote.source = translation.source;
translatedCount++;
}
});
// Save updated file
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Batch 3 complete: ${translatedCount} quotes translated`);
console.log(
`📊 Total translated so far: 60 + ${translatedCount} = ${60 + translatedCount} quotes`
);
return translatedCount;
} catch (error) {
console.error('❌ Batch 3 translation failed:', error);
return 0;
}
}
if (require.main === module) {
translateBatch3();
translateBatch3();
}
module.exports = { translateBatch3 };
module.exports = { translateBatch3 };

View file

@ -9,162 +9,223 @@ const path = require('path');
// Translations for the final 100 quotes (q-101 to q-200)
const finalBatchTranslations = {
// Famous philosophical and motivational quotes
'q-101': { text: 'The only constant in life is change.' },
'q-102': { text: 'Knowing yourself is the beginning of all wisdom.' },
'q-103': { text: 'We are what we repeatedly do. Excellence, then, is not an act, but a habit.' },
'q-104': { text: 'The whole is greater than the sum of its parts.' },
'q-105': { text: 'Well begun is half done.' },
'q-106': { text: 'Patience is bitter, but its fruit is sweet.' },
'q-107': { text: 'There is only one good, knowledge, and one evil, ignorance.' },
'q-108': { text: 'Man is by nature a social animal.' },
'q-109': { text: 'Hope is a waking dream.' },
'q-110': { text: 'The aim of art is to represent not the outward appearance of things, but their inward significance.' },
// More wisdom quotes
'q-111': { text: 'Time is the most valuable thing we have and can use.' },
'q-112': { text: 'Doubt is the beginning, not the end, of wisdom.' },
'q-113': { text: 'The secret of change is to focus all of your energy not on fighting the old, but on building the new.' },
'q-114': { text: 'A wise man learns more from his enemies than a fool from his friends.' },
'q-115': { text: 'The measure of intelligence is the ability to change.' },
'q-116': { text: 'Life is really simple, but we insist on making it complicated.' },
'q-117': { text: 'It does not matter how slowly you go as long as you do not stop.' },
'q-118': { text: 'Our greatest glory is not in never falling, but in rising every time we fall.' },
'q-119': { text: 'Choose a job you love, and you will never have to work a day in your life.' },
'q-120': { text: 'The man who moves a mountain begins by carrying away small stones.' },
// Scientific and philosophical insights
'q-121': { text: 'Simplicity is the ultimate sophistication.' },
'q-122': { text: 'Learning never exhausts the mind.' },
'q-123': { text: 'Where the spirit does not work with the hand, there is no art.' },
'q-124': { text: 'Nature never hurries, yet everything is accomplished.' },
'q-125': { text: 'The journey of a thousand miles begins with one step.' },
'q-126': { text: 'When I let go of what I am, I become what I might be.' },
'q-127': { text: 'At the center of your being you have the answer; you know who you are and you know what you want.' },
'q-128': { text: 'New beginnings are often disguised as painful endings.' },
'q-129': { text: 'If you understand others you are smart. If you understand yourself you are illuminated.' },
'q-130': { text: 'The sage does not attempt anything very big, and thus achieves greatness.' },
// Modern wisdom and life philosophy
'q-131': { text: 'Success is not the key to happiness. Happiness is the key to success.' },
'q-132': { text: 'The only person you are destined to become is the person you decide to be.' },
'q-133': { text: 'What we think, we become.' },
'q-134': { text: 'The mind is everything. What you think you become.' },
'q-135': { text: 'Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.' },
'q-136': { text: 'Three things cannot be long hidden: the sun, the moon, and the truth.' },
'q-137': { text: 'Peace comes from within. Do not seek it without.' },
'q-138': { text: 'Better than a thousand hollow words, is one word that brings peace.' },
'q-139': { text: 'Hatred does not cease by hatred, but only by love; this is the eternal rule.' },
'q-140': { text: 'You yourself, as much as anybody in the entire universe, deserve your love and affection.' },
// Leadership and success
'q-141': { text: 'A leader is one who knows the way, goes the way, and shows the way.' },
'q-142': { text: 'The best time to plant a tree was 20 years ago. The second best time is now.' },
'q-143': { text: 'It is better to travel well than to arrive.' },
'q-144': { text: 'The only way to make sense out of change is to plunge into it, move with it, and join the dance.' },
'q-145': { text: 'Yesterday is history, tomorrow is a mystery, today is a gift of God, which is why we call it the present.' },
'q-146': { text: 'You have power over your mind - not outside events. Realize this, and you will find strength.' },
'q-147': { text: 'Very little is needed to make a happy life; it is all within yourself, in your way of thinking.' },
'q-148': { text: 'Waste no more time arguing what a good man should be. Be one.' },
'q-149': { text: 'The happiness of your life depends upon the quality of your thoughts.' },
'q-150': { text: 'Accept the things to which fate binds you, and love the people with whom fate brings you together.' },
// Final 50 quotes - inspirational and profound
'q-151': { text: 'When we are no longer able to change a situation, we are challenged to change ourselves.' },
'q-152': { text: 'Those who have a \'why\' to live, can bear with almost any \'how\'.' },
'q-153': { text: 'Everything can be taken from a man but one thing: the freedom to choose one\'s attitude.' },
'q-154': { text: 'What is to give light must endure burning.' },
'q-155': { text: 'A thought transfixed me: for the first time in my life, I saw the truth as it is set into song by so many poets, proclaimed as the final wisdom by so many thinkers. The truth - that love is the ultimate and the highest goal to which man can aspire.' },
'q-156': { text: 'Don\'t aim at success. The more you aim at it and make it a target, the more you are going to miss it.' },
'q-157': { text: 'Happiness cannot be traveled to, owned, earned, worn or consumed. Happiness is the spiritual experience of living every minute with love, grace, and gratitude.' },
'q-158': { text: 'The most beautiful people we have known are those who have known defeat, known suffering, known struggle, known loss, and have found their way out of those depths.' },
'q-159': { text: 'A ship in harbor is safe, but that is not what ships are built for.' },
'q-160': { text: 'Twenty years from now you will be more disappointed by the things that you didn\'t do than by the ones you did do.' },
'q-161': { text: 'The greatest glory in living lies not in never falling, but in rising every time we fall.' },
'q-162': { text: 'The way to get started is to quit talking and begin doing.' },
'q-163': { text: 'If life were predictable it would cease to be life, and be without flavor.' },
'q-164': { text: 'Spread love everywhere you go. Let no one ever come to you without leaving happier.' },
'q-165': { text: 'When you reach the end of your rope, tie a knot in it and hang on.' },
'q-166': { text: 'Always remember that you are absolutely unique. Just like everyone else.' },
'q-167': { text: 'Don\'t judge each day by the harvest you reap but by the seeds that you plant.' },
'q-168': { text: 'Tell me and I forget. Teach me and I remember. Involve me and I learn.' },
'q-169': { text: 'It is during our darkest moments that we must focus to see the light.' },
'q-170': { text: 'Whoever is happy will make others happy too.' },
'q-171': { text: 'Do not go where the path may lead, go instead where there is no path and leave a trail.' },
'q-172': { text: 'You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose.' },
'q-173': { text: 'In the end, it\'s not the years in your life that count. It\'s the life in your years.' },
'q-174': { text: 'Life is a succession of lessons which must be lived to be understood.' },
'q-175': { text: 'You have been assigned this mountain to show others it can be moved.' },
'q-176': { text: 'Your limitation—it\'s only your imagination.' },
'q-177': { text: 'Push yourself, because no one else is going to do it for you.' },
'q-178': { text: 'Great things never come from comfort zones.' },
'q-179': { text: 'Dream it. Wish it. Do it.' },
'q-180': { text: 'Success doesn\'t just find you. You have to go out and get it.' },
'q-181': { text: 'The harder you work for something, the greater you\'ll feel when you achieve it.' },
'q-182': { text: 'Dream bigger. Do bigger.' },
'q-183': { text: 'Don\'t stop when you\'re tired. Stop when you\'re done.' },
'q-184': { text: 'Wake up with determination. Go to bed with satisfaction.' },
'q-185': { text: 'Do something today that your future self will thank you for.' },
'q-186': { text: 'Little things make big days.' },
'q-187': { text: 'It\'s going to be hard, but hard does not mean impossible.' },
'q-188': { text: 'Don\'t wait for opportunity. Create it.' },
'q-189': { text: 'Sometimes we\'re tested not to show our weaknesses, but to discover our strengths.' },
'q-190': { text: 'The key to success is to focus on goals, not obstacles.' },
'q-191': { text: 'Dream it. Believe it. Build it.' },
'q-192': { text: 'What lies behind us and what lies before us are tiny matters compared to what lies within us.' },
'q-193': { text: 'Believe you can and you\'re halfway there.' },
'q-194': { text: 'Act as if what you do makes a difference. It does.' },
'q-195': { text: 'Success is not how high you have climbed, but how you make a positive difference to the world.' },
'q-196': { text: 'What we achieve inwardly will change outer reality.' },
'q-197': { text: 'A champion is someone who gets up when they can\'t.' },
'q-198': { text: 'Believe in yourself and all that you are. Know that there is something inside you that is greater than any obstacle.' },
'q-199': { text: 'You are never too old to set another goal or to dream a new dream.' },
'q-200': { text: 'The future belongs to those who believe in the beauty of their dreams.' }
// Famous philosophical and motivational quotes
'q-101': { text: 'The only constant in life is change.' },
'q-102': { text: 'Knowing yourself is the beginning of all wisdom.' },
'q-103': { text: 'We are what we repeatedly do. Excellence, then, is not an act, but a habit.' },
'q-104': { text: 'The whole is greater than the sum of its parts.' },
'q-105': { text: 'Well begun is half done.' },
'q-106': { text: 'Patience is bitter, but its fruit is sweet.' },
'q-107': { text: 'There is only one good, knowledge, and one evil, ignorance.' },
'q-108': { text: 'Man is by nature a social animal.' },
'q-109': { text: 'Hope is a waking dream.' },
'q-110': {
text: 'The aim of art is to represent not the outward appearance of things, but their inward significance.',
},
// More wisdom quotes
'q-111': { text: 'Time is the most valuable thing we have and can use.' },
'q-112': { text: 'Doubt is the beginning, not the end, of wisdom.' },
'q-113': {
text: 'The secret of change is to focus all of your energy not on fighting the old, but on building the new.',
},
'q-114': { text: 'A wise man learns more from his enemies than a fool from his friends.' },
'q-115': { text: 'The measure of intelligence is the ability to change.' },
'q-116': { text: 'Life is really simple, but we insist on making it complicated.' },
'q-117': { text: 'It does not matter how slowly you go as long as you do not stop.' },
'q-118': {
text: 'Our greatest glory is not in never falling, but in rising every time we fall.',
},
'q-119': { text: 'Choose a job you love, and you will never have to work a day in your life.' },
'q-120': { text: 'The man who moves a mountain begins by carrying away small stones.' },
// Scientific and philosophical insights
'q-121': { text: 'Simplicity is the ultimate sophistication.' },
'q-122': { text: 'Learning never exhausts the mind.' },
'q-123': { text: 'Where the spirit does not work with the hand, there is no art.' },
'q-124': { text: 'Nature never hurries, yet everything is accomplished.' },
'q-125': { text: 'The journey of a thousand miles begins with one step.' },
'q-126': { text: 'When I let go of what I am, I become what I might be.' },
'q-127': {
text: 'At the center of your being you have the answer; you know who you are and you know what you want.',
},
'q-128': { text: 'New beginnings are often disguised as painful endings.' },
'q-129': {
text: 'If you understand others you are smart. If you understand yourself you are illuminated.',
},
'q-130': { text: 'The sage does not attempt anything very big, and thus achieves greatness.' },
// Modern wisdom and life philosophy
'q-131': { text: 'Success is not the key to happiness. Happiness is the key to success.' },
'q-132': { text: 'The only person you are destined to become is the person you decide to be.' },
'q-133': { text: 'What we think, we become.' },
'q-134': { text: 'The mind is everything. What you think you become.' },
'q-135': {
text: 'Do not dwell in the past, do not dream of the future, concentrate the mind on the present moment.',
},
'q-136': { text: 'Three things cannot be long hidden: the sun, the moon, and the truth.' },
'q-137': { text: 'Peace comes from within. Do not seek it without.' },
'q-138': { text: 'Better than a thousand hollow words, is one word that brings peace.' },
'q-139': { text: 'Hatred does not cease by hatred, but only by love; this is the eternal rule.' },
'q-140': {
text: 'You yourself, as much as anybody in the entire universe, deserve your love and affection.',
},
// Leadership and success
'q-141': { text: 'A leader is one who knows the way, goes the way, and shows the way.' },
'q-142': { text: 'The best time to plant a tree was 20 years ago. The second best time is now.' },
'q-143': { text: 'It is better to travel well than to arrive.' },
'q-144': {
text: 'The only way to make sense out of change is to plunge into it, move with it, and join the dance.',
},
'q-145': {
text: 'Yesterday is history, tomorrow is a mystery, today is a gift of God, which is why we call it the present.',
},
'q-146': {
text: 'You have power over your mind - not outside events. Realize this, and you will find strength.',
},
'q-147': {
text: 'Very little is needed to make a happy life; it is all within yourself, in your way of thinking.',
},
'q-148': { text: 'Waste no more time arguing what a good man should be. Be one.' },
'q-149': { text: 'The happiness of your life depends upon the quality of your thoughts.' },
'q-150': {
text: 'Accept the things to which fate binds you, and love the people with whom fate brings you together.',
},
// Final 50 quotes - inspirational and profound
'q-151': {
text: 'When we are no longer able to change a situation, we are challenged to change ourselves.',
},
'q-152': { text: "Those who have a 'why' to live, can bear with almost any 'how'." },
'q-153': {
text: "Everything can be taken from a man but one thing: the freedom to choose one's attitude.",
},
'q-154': { text: 'What is to give light must endure burning.' },
'q-155': {
text: 'A thought transfixed me: for the first time in my life, I saw the truth as it is set into song by so many poets, proclaimed as the final wisdom by so many thinkers. The truth - that love is the ultimate and the highest goal to which man can aspire.',
},
'q-156': {
text: "Don't aim at success. The more you aim at it and make it a target, the more you are going to miss it.",
},
'q-157': {
text: 'Happiness cannot be traveled to, owned, earned, worn or consumed. Happiness is the spiritual experience of living every minute with love, grace, and gratitude.',
},
'q-158': {
text: 'The most beautiful people we have known are those who have known defeat, known suffering, known struggle, known loss, and have found their way out of those depths.',
},
'q-159': { text: 'A ship in harbor is safe, but that is not what ships are built for.' },
'q-160': {
text: "Twenty years from now you will be more disappointed by the things that you didn't do than by the ones you did do.",
},
'q-161': {
text: 'The greatest glory in living lies not in never falling, but in rising every time we fall.',
},
'q-162': { text: 'The way to get started is to quit talking and begin doing.' },
'q-163': { text: 'If life were predictable it would cease to be life, and be without flavor.' },
'q-164': {
text: 'Spread love everywhere you go. Let no one ever come to you without leaving happier.',
},
'q-165': { text: 'When you reach the end of your rope, tie a knot in it and hang on.' },
'q-166': { text: 'Always remember that you are absolutely unique. Just like everyone else.' },
'q-167': {
text: "Don't judge each day by the harvest you reap but by the seeds that you plant.",
},
'q-168': { text: 'Tell me and I forget. Teach me and I remember. Involve me and I learn.' },
'q-169': { text: 'It is during our darkest moments that we must focus to see the light.' },
'q-170': { text: 'Whoever is happy will make others happy too.' },
'q-171': {
text: 'Do not go where the path may lead, go instead where there is no path and leave a trail.',
},
'q-172': {
text: 'You have brains in your head. You have feet in your shoes. You can steer yourself any direction you choose.',
},
'q-173': {
text: "In the end, it's not the years in your life that count. It's the life in your years.",
},
'q-174': { text: 'Life is a succession of lessons which must be lived to be understood.' },
'q-175': { text: 'You have been assigned this mountain to show others it can be moved.' },
'q-176': { text: "Your limitation—it's only your imagination." },
'q-177': { text: 'Push yourself, because no one else is going to do it for you.' },
'q-178': { text: 'Great things never come from comfort zones.' },
'q-179': { text: 'Dream it. Wish it. Do it.' },
'q-180': { text: "Success doesn't just find you. You have to go out and get it." },
'q-181': {
text: "The harder you work for something, the greater you'll feel when you achieve it.",
},
'q-182': { text: 'Dream bigger. Do bigger.' },
'q-183': { text: "Don't stop when you're tired. Stop when you're done." },
'q-184': { text: 'Wake up with determination. Go to bed with satisfaction.' },
'q-185': { text: 'Do something today that your future self will thank you for.' },
'q-186': { text: 'Little things make big days.' },
'q-187': { text: "It's going to be hard, but hard does not mean impossible." },
'q-188': { text: "Don't wait for opportunity. Create it." },
'q-189': {
text: "Sometimes we're tested not to show our weaknesses, but to discover our strengths.",
},
'q-190': { text: 'The key to success is to focus on goals, not obstacles.' },
'q-191': { text: 'Dream it. Believe it. Build it.' },
'q-192': {
text: 'What lies behind us and what lies before us are tiny matters compared to what lies within us.',
},
'q-193': { text: "Believe you can and you're halfway there." },
'q-194': { text: 'Act as if what you do makes a difference. It does.' },
'q-195': {
text: 'Success is not how high you have climbed, but how you make a positive difference to the world.',
},
'q-196': { text: 'What we achieve inwardly will change outer reality.' },
'q-197': { text: "A champion is someone who gets up when they can't." },
'q-198': {
text: 'Believe in yourself and all that you are. Know that there is something inside you that is greater than any obstacle.',
},
'q-199': { text: 'You are never too old to set another goal or to dream a new dream.' },
'q-200': { text: 'The future belongs to those who believe in the beauty of their dreams.' },
};
async function translateFinalBatch() {
console.log('🏁 Starting Final Batch Translation (q-101 to q-200)...\n');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current English quotes
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Apply translations
enQuotes.quotes.forEach((quote) => {
const translation = finalBatchTranslations[quote.id];
if (translation) {
quote.text = translation.text;
if (translation.context) quote.context = translation.context;
if (translation.source) quote.source = translation.source;
translatedCount++;
}
});
// Save updated file
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Final batch complete: ${translatedCount} quotes translated`);
console.log(`🎉 TOTAL TRANSLATION COMPLETE!`);
console.log(`📊 Total quotes translated: 100 + ${translatedCount} = ${100 + translatedCount} out of 200`);
return translatedCount;
} catch (error) {
console.error('❌ Final batch translation failed:', error);
return 0;
}
console.log('🏁 Starting Final Batch Translation (q-101 to q-200)...\n');
const enQuotesPath = path.join(__dirname, '../content/data/en/quotes.json');
try {
// Load current English quotes
const enQuotes = JSON.parse(fs.readFileSync(enQuotesPath, 'utf8'));
let translatedCount = 0;
// Apply translations
enQuotes.quotes.forEach((quote) => {
const translation = finalBatchTranslations[quote.id];
if (translation) {
quote.text = translation.text;
if (translation.context) quote.context = translation.context;
if (translation.source) quote.source = translation.source;
translatedCount++;
}
});
// Save updated file
fs.writeFileSync(enQuotesPath, JSON.stringify(enQuotes, null, 2), 'utf8');
console.log(`✅ Final batch complete: ${translatedCount} quotes translated`);
console.log(`🎉 TOTAL TRANSLATION COMPLETE!`);
console.log(
`📊 Total quotes translated: 100 + ${translatedCount} = ${100 + translatedCount} out of 200`
);
return translatedCount;
} catch (error) {
console.error('❌ Final batch translation failed:', error);
return 0;
}
}
if (require.main === module) {
translateFinalBatch();
translateFinalBatch();
}
module.exports = { translateFinalBatch };
module.exports = { translateFinalBatch };

View file

@ -8,211 +8,220 @@ import * as path from 'path';
import matter from 'gray-matter';
interface Biography {
short: string;
long?: string;
sections?: {
[key: string]: {
title: string;
content: string;
};
};
keyAchievements?: string[];
famousQuote?: string;
short: string;
long?: string;
sections?: {
[key: string]: {
title: string;
content: string;
};
};
keyAchievements?: string[];
famousQuote?: string;
}
function parseMarkdownFile(content: string): Biography | null {
try {
const { data: frontMatter, content: markdownContent } = matter(content);
if (!markdownContent) return null;
try {
const { data: frontMatter, content: markdownContent } = matter(content);
const lines = markdownContent.split('\n').filter(line => line.trim());
const biography: Biography = {
short: '',
sections: {},
keyAchievements: []
};
if (!markdownContent) return null;
// Extract short biography from "Kurzbiografie" section
const kurzBioIndex = lines.findIndex(line => line.includes('## Kurzbiografie'));
if (kurzBioIndex !== -1) {
let shortBio = '';
let paragraphCount = 0;
for (let i = kurzBioIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith('#')) break;
if (lines[i].trim()) {
shortBio += (shortBio ? ' ' : '') + lines[i].trim();
if (lines[i].endsWith('.')) {
paragraphCount++;
if (paragraphCount >= 2) break; // Take first 2 paragraphs
}
}
}
biography.short = shortBio;
biography.long = shortBio;
}
const lines = markdownContent.split('\n').filter((line) => line.trim());
const biography: Biography = {
short: '',
sections: {},
keyAchievements: [],
};
// Extract main sections
let currentSection: string | null = null;
let currentContent: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('## ') || line.startsWith('### ')) {
// Save previous section if exists
if (currentSection && currentContent.length > 0) {
const sectionKey = normalizeKey(currentSection);
if (!['kurzbiografie', 'beruehmtezitate', 'weiterfuehrendelinks'].includes(sectionKey)) {
biography.sections![sectionKey] = {
title: currentSection,
content: currentContent.join(' ').trim()
};
}
}
// Start new section
currentSection = line.replace(/^#+\s*/, '').trim();
currentContent = [];
} else if (currentSection && line.trim() && !line.startsWith('>')) {
// Skip quotes and collect content
if (line.startsWith('-') || line.startsWith('*')) {
// This might be a key achievement
const item = line.replace(/^[-*]\s*/, '').trim();
if (currentSection.toLowerCase().includes('achievement') ||
currentSection.toLowerCase().includes('errungenschaft') ||
currentSection.toLowerCase().includes('werk') ||
currentSection.toLowerCase().includes('bedeutung')) {
if (!biography.keyAchievements) biography.keyAchievements = [];
biography.keyAchievements.push(item);
}
} else {
currentContent.push(line.trim());
}
} else if (line.startsWith('>')) {
// Extract famous quote
if (!biography.famousQuote) {
biography.famousQuote = line.replace(/^>\s*"?/, '').replace(/"?$/, '').trim();
}
}
}
// Save last section
if (currentSection && currentContent.length > 0) {
const sectionKey = normalizeKey(currentSection);
if (!['kurzbiografie', 'beruehmtezitate', 'weiterfuehrendelinks'].includes(sectionKey)) {
biography.sections![sectionKey] = {
title: currentSection,
content: currentContent.join(' ').trim()
};
}
}
// Extract short biography from "Kurzbiografie" section
const kurzBioIndex = lines.findIndex((line) => line.includes('## Kurzbiografie'));
if (kurzBioIndex !== -1) {
let shortBio = '';
let paragraphCount = 0;
for (let i = kurzBioIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith('#')) break;
if (lines[i].trim()) {
shortBio += (shortBio ? ' ' : '') + lines[i].trim();
if (lines[i].endsWith('.')) {
paragraphCount++;
if (paragraphCount >= 2) break; // Take first 2 paragraphs
}
}
}
biography.short = shortBio;
biography.long = shortBio;
}
// Limit sections to the most important ones
if (biography.sections && Object.keys(biography.sections).length > 5) {
const prioritySections = ['leben', 'werk', 'philosophie', 'bedeutung', 'vermaechtnis'];
const importantSections: any = {};
// First, add priority sections if they exist
for (const key of prioritySections) {
if (biography.sections[key]) {
importantSections[key] = biography.sections[key];
}
}
// Then add others until we have 5
for (const [key, section] of Object.entries(biography.sections)) {
if (Object.keys(importantSections).length >= 5) break;
if (!importantSections[key]) {
importantSections[key] = section;
}
}
biography.sections = importantSections;
}
// Extract main sections
let currentSection: string | null = null;
let currentContent: string[] = [];
// Limit key achievements to 5
if (biography.keyAchievements && biography.keyAchievements.length > 5) {
biography.keyAchievements = biography.keyAchievements.slice(0, 5);
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Ensure we have at least a short biography
if (!biography.short && Object.keys(biography.sections || {}).length > 0) {
// Take the first section as short biography
const firstSection = Object.values(biography.sections!)[0];
biography.short = firstSection.content.substring(0, 200) + '...';
}
if (line.startsWith('## ') || line.startsWith('### ')) {
// Save previous section if exists
if (currentSection && currentContent.length > 0) {
const sectionKey = normalizeKey(currentSection);
if (!['kurzbiografie', 'beruehmtezitate', 'weiterfuehrendelinks'].includes(sectionKey)) {
biography.sections![sectionKey] = {
title: currentSection,
content: currentContent.join(' ').trim(),
};
}
}
return biography.short ? biography : null;
} catch (error) {
console.error('Error parsing markdown file:', error);
return null;
}
// Start new section
currentSection = line.replace(/^#+\s*/, '').trim();
currentContent = [];
} else if (currentSection && line.trim() && !line.startsWith('>')) {
// Skip quotes and collect content
if (line.startsWith('-') || line.startsWith('*')) {
// This might be a key achievement
const item = line.replace(/^[-*]\s*/, '').trim();
if (
currentSection.toLowerCase().includes('achievement') ||
currentSection.toLowerCase().includes('errungenschaft') ||
currentSection.toLowerCase().includes('werk') ||
currentSection.toLowerCase().includes('bedeutung')
) {
if (!biography.keyAchievements) biography.keyAchievements = [];
biography.keyAchievements.push(item);
}
} else {
currentContent.push(line.trim());
}
} else if (line.startsWith('>')) {
// Extract famous quote
if (!biography.famousQuote) {
biography.famousQuote = line
.replace(/^>\s*"?/, '')
.replace(/"?$/, '')
.trim();
}
}
}
// Save last section
if (currentSection && currentContent.length > 0) {
const sectionKey = normalizeKey(currentSection);
if (!['kurzbiografie', 'beruehmtezitate', 'weiterfuehrendelinks'].includes(sectionKey)) {
biography.sections![sectionKey] = {
title: currentSection,
content: currentContent.join(' ').trim(),
};
}
}
// Limit sections to the most important ones
if (biography.sections && Object.keys(biography.sections).length > 5) {
const prioritySections = ['leben', 'werk', 'philosophie', 'bedeutung', 'vermaechtnis'];
const importantSections: any = {};
// First, add priority sections if they exist
for (const key of prioritySections) {
if (biography.sections[key]) {
importantSections[key] = biography.sections[key];
}
}
// Then add others until we have 5
for (const [key, section] of Object.entries(biography.sections)) {
if (Object.keys(importantSections).length >= 5) break;
if (!importantSections[key]) {
importantSections[key] = section;
}
}
biography.sections = importantSections;
}
// Limit key achievements to 5
if (biography.keyAchievements && biography.keyAchievements.length > 5) {
biography.keyAchievements = biography.keyAchievements.slice(0, 5);
}
// Ensure we have at least a short biography
if (!biography.short && Object.keys(biography.sections || {}).length > 0) {
// Take the first section as short biography
const firstSection = Object.values(biography.sections!)[0];
biography.short = firstSection.content.substring(0, 200) + '...';
}
return biography.short ? biography : null;
} catch (error) {
console.error('Error parsing markdown file:', error);
return null;
}
}
function normalizeKey(title: string): string {
return title.toLowerCase()
.replace(/[äöüß]/g, (match) => {
const replacements: { [key: string]: string } = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss'
};
return replacements[match] || match;
})
.replace(/[^a-z0-9]/g, '');
return title
.toLowerCase()
.replace(/[äöüß]/g, (match) => {
const replacements: { [key: string]: string } = {
ä: 'ae',
ö: 'oe',
ü: 'ue',
ß: 'ss',
};
return replacements[match] || match;
})
.replace(/[^a-z0-9]/g, '');
}
async function buildBiographies() {
const profilesPath = path.join(process.cwd(), 'content', 'authors', 'profiles');
const outputPath = path.join(process.cwd(), 'content', 'generated');
const outputFile = path.join(outputPath, 'biographies.json');
const profilesPath = path.join(process.cwd(), 'content', 'authors', 'profiles');
const outputPath = path.join(process.cwd(), 'content', 'generated');
const outputFile = path.join(outputPath, 'biographies.json');
if (!fs.existsSync(profilesPath)) {
console.error('Profiles directory not found:', profilesPath);
return;
}
if (!fs.existsSync(profilesPath)) {
console.error('Profiles directory not found:', profilesPath);
return;
}
// Create output directory if it doesn't exist
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
// Create output directory if it doesn't exist
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const files = fs.readdirSync(profilesPath).filter(f => f.endsWith('.md'));
const biographies: { [key: string]: Biography } = {};
const files = fs.readdirSync(profilesPath).filter((f) => f.endsWith('.md'));
const biographies: { [key: string]: Biography } = {};
console.log(`Processing ${files.length} biography files...`);
console.log(`Processing ${files.length} biography files...`);
let successCount = 0;
let errorCount = 0;
let successCount = 0;
let errorCount = 0;
for (const file of files) {
const filePath = path.join(profilesPath, file);
const content = fs.readFileSync(filePath, 'utf-8');
// Extract ID from frontmatter or filename
const idMatch = content.match(/^id:\s*(.+)$/m);
const id = idMatch ? idMatch[1].trim() : file.replace('.md', '');
const biography = parseMarkdownFile(content);
if (biography) {
biographies[id] = biography;
successCount++;
console.log(`✓ Processed ${id}`);
} else {
errorCount++;
console.log(`✗ Failed to process ${file}`);
}
}
for (const file of files) {
const filePath = path.join(profilesPath, file);
const content = fs.readFileSync(filePath, 'utf-8');
// Write the compiled biographies to JSON
fs.writeFileSync(outputFile, JSON.stringify(biographies, null, 2), 'utf-8');
// Extract ID from frontmatter or filename
const idMatch = content.match(/^id:\s*(.+)$/m);
const id = idMatch ? idMatch[1].trim() : file.replace('.md', '');
console.log('\n=== Build Complete ===');
console.log(`✓ Successfully processed: ${successCount}`);
console.log(`✗ Errors: ${errorCount}`);
console.log(`📁 Output: ${outputFile}`);
console.log(`📊 Total size: ${(fs.statSync(outputFile).size / 1024).toFixed(2)} KB`);
const biography = parseMarkdownFile(content);
if (biography) {
biographies[id] = biography;
successCount++;
console.log(`✓ Processed ${id}`);
} else {
errorCount++;
console.log(`✗ Failed to process ${file}`);
}
}
// Write the compiled biographies to JSON
fs.writeFileSync(outputFile, JSON.stringify(biographies, null, 2), 'utf-8');
console.log('\n=== Build Complete ===');
console.log(`✓ Successfully processed: ${successCount}`);
console.log(`✗ Errors: ${errorCount}`);
console.log(`📁 Output: ${outputFile}`);
console.log(`📊 Total size: ${(fs.statSync(outputFile).size / 1024).toFixed(2)} KB`);
}
// Run the build
buildBiographies().catch(console.error);
buildBiographies().catch(console.error);

View file

@ -10,256 +10,266 @@ import * as path from 'path';
import matter from 'gray-matter';
interface Biography {
short: string;
long?: string;
sections?: { [key: string]: any };
keyAchievements?: string[];
famousQuote?: string;
short: string;
long?: string;
sections?: { [key: string]: any };
keyAchievements?: string[];
famousQuote?: string;
}
interface BiographyReport {
id: string;
hasMarkdown: boolean;
hasJson: boolean;
markdownSize: number;
jsonSize: number;
hasShortBio: boolean;
hasLongBio: boolean;
sectionsCount: number;
achievementsCount: number;
hasFamousQuote: boolean;
completeness: number; // percentage
issues: string[];
id: string;
hasMarkdown: boolean;
hasJson: boolean;
markdownSize: number;
jsonSize: number;
hasShortBio: boolean;
hasLongBio: boolean;
sectionsCount: number;
achievementsCount: number;
hasFamousQuote: boolean;
completeness: number; // percentage
issues: string[];
}
function checkMarkdownFile(filePath: string): { size: number; hasContent: boolean } {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const { data: frontMatter, content: markdownContent } = matter(content);
return {
size: content.length,
hasContent: markdownContent.trim().length > 100
};
} catch (error) {
return { size: 0, hasContent: false };
}
try {
const content = fs.readFileSync(filePath, 'utf-8');
const { data: frontMatter, content: markdownContent } = matter(content);
return {
size: content.length,
hasContent: markdownContent.trim().length > 100,
};
} catch (error) {
return { size: 0, hasContent: false };
}
}
function checkJsonBiography(bio: Biography | undefined): {
hasShortBio: boolean;
hasLongBio: boolean;
sectionsCount: number;
achievementsCount: number;
hasFamousQuote: boolean;
jsonSize: number;
hasShortBio: boolean;
hasLongBio: boolean;
sectionsCount: number;
achievementsCount: number;
hasFamousQuote: boolean;
jsonSize: number;
} {
if (!bio) {
return {
hasShortBio: false,
hasLongBio: false,
sectionsCount: 0,
achievementsCount: 0,
hasFamousQuote: false,
jsonSize: 0
};
}
if (!bio) {
return {
hasShortBio: false,
hasLongBio: false,
sectionsCount: 0,
achievementsCount: 0,
hasFamousQuote: false,
jsonSize: 0,
};
}
return {
hasShortBio: !!(bio.short && bio.short.length > 10),
hasLongBio: !!(bio.long && bio.long.length > 50),
sectionsCount: Object.keys(bio.sections || {}).length,
achievementsCount: (bio.keyAchievements || []).length,
hasFamousQuote: !!bio.famousQuote,
jsonSize: JSON.stringify(bio).length
};
return {
hasShortBio: !!(bio.short && bio.short.length > 10),
hasLongBio: !!(bio.long && bio.long.length > 50),
sectionsCount: Object.keys(bio.sections || {}).length,
achievementsCount: (bio.keyAchievements || []).length,
hasFamousQuote: !!bio.famousQuote,
jsonSize: JSON.stringify(bio).length,
};
}
function calculateCompleteness(report: Partial<BiographyReport>): number {
let score = 0;
let maxScore = 0;
let score = 0;
let maxScore = 0;
// Essential components
if (report.hasMarkdown) { score += 20; }
maxScore += 20;
// Essential components
if (report.hasMarkdown) {
score += 20;
}
maxScore += 20;
if (report.hasJson) { score += 20; }
maxScore += 20;
if (report.hasJson) {
score += 20;
}
maxScore += 20;
if (report.hasShortBio) { score += 15; }
maxScore += 15;
if (report.hasShortBio) {
score += 15;
}
maxScore += 15;
if (report.hasLongBio) { score += 15; }
maxScore += 15;
if (report.hasLongBio) {
score += 15;
}
maxScore += 15;
// Additional components
if (report.sectionsCount && report.sectionsCount > 0) {
score += Math.min(report.sectionsCount * 3, 15);
}
maxScore += 15;
// Additional components
if (report.sectionsCount && report.sectionsCount > 0) {
score += Math.min(report.sectionsCount * 3, 15);
}
maxScore += 15;
if (report.achievementsCount && report.achievementsCount > 0) {
score += Math.min(report.achievementsCount * 2, 10);
}
maxScore += 10;
if (report.achievementsCount && report.achievementsCount > 0) {
score += Math.min(report.achievementsCount * 2, 10);
}
maxScore += 10;
if (report.hasFamousQuote) { score += 5; }
maxScore += 5;
if (report.hasFamousQuote) {
score += 5;
}
maxScore += 5;
return Math.round((score / maxScore) * 100);
return Math.round((score / maxScore) * 100);
}
function analyzeAllBiographies(): BiographyReport[] {
const profilesDir = path.join(process.cwd(), 'content', 'authors', 'profiles');
const jsonPath = path.join(process.cwd(), 'content', 'generated', 'biographies.json');
const profilesDir = path.join(process.cwd(), 'content', 'authors', 'profiles');
const jsonPath = path.join(process.cwd(), 'content', 'generated', 'biographies.json');
// Load JSON biographies
let jsonBiographies: { [key: string]: Biography } = {};
try {
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
jsonBiographies = JSON.parse(jsonContent);
} catch (error) {
console.error('Could not load biographies.json:', error);
}
// Load JSON biographies
let jsonBiographies: { [key: string]: Biography } = {};
try {
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
jsonBiographies = JSON.parse(jsonContent);
} catch (error) {
console.error('Could not load biographies.json:', error);
}
// Get all markdown files
const markdownFiles = fs.readdirSync(profilesDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace('.md', ''));
// Get all markdown files
const markdownFiles = fs
.readdirSync(profilesDir)
.filter((f) => f.endsWith('.md'))
.map((f) => f.replace('.md', ''));
// Get all IDs from both sources
const allIds = new Set([...markdownFiles, ...Object.keys(jsonBiographies)]);
// Get all IDs from both sources
const allIds = new Set([...markdownFiles, ...Object.keys(jsonBiographies)]);
const reports: BiographyReport[] = [];
const reports: BiographyReport[] = [];
for (const id of allIds) {
const markdownPath = path.join(profilesDir, `${id}.md`);
const markdownInfo = checkMarkdownFile(markdownPath);
const jsonBio = jsonBiographies[id];
const jsonInfo = checkJsonBiography(jsonBio);
for (const id of allIds) {
const markdownPath = path.join(profilesDir, `${id}.md`);
const markdownInfo = checkMarkdownFile(markdownPath);
const jsonBio = jsonBiographies[id];
const jsonInfo = checkJsonBiography(jsonBio);
const report: BiographyReport = {
id,
hasMarkdown: fs.existsSync(markdownPath),
hasJson: !!jsonBio,
markdownSize: markdownInfo.size,
jsonSize: jsonInfo.jsonSize,
hasShortBio: jsonInfo.hasShortBio,
hasLongBio: jsonInfo.hasLongBio,
sectionsCount: jsonInfo.sectionsCount,
achievementsCount: jsonInfo.achievementsCount,
hasFamousQuote: jsonInfo.hasFamousQuote,
completeness: 0,
issues: []
};
const report: BiographyReport = {
id,
hasMarkdown: fs.existsSync(markdownPath),
hasJson: !!jsonBio,
markdownSize: markdownInfo.size,
jsonSize: jsonInfo.jsonSize,
hasShortBio: jsonInfo.hasShortBio,
hasLongBio: jsonInfo.hasLongBio,
sectionsCount: jsonInfo.sectionsCount,
achievementsCount: jsonInfo.achievementsCount,
hasFamousQuote: jsonInfo.hasFamousQuote,
completeness: 0,
issues: [],
};
// Identify issues
if (!report.hasMarkdown) {
report.issues.push('No Markdown file');
}
if (!report.hasJson) {
report.issues.push('Not in JSON');
}
if (report.hasMarkdown && !markdownInfo.hasContent) {
report.issues.push('Markdown file too short');
}
if (!report.hasShortBio) {
report.issues.push('Missing short biography');
}
if (!report.hasLongBio && report.markdownSize > 500) {
report.issues.push('Long biography not extracted');
}
if (report.sectionsCount === 0 && report.markdownSize > 1000) {
report.issues.push('No sections extracted from large file');
}
// Identify issues
if (!report.hasMarkdown) {
report.issues.push('No Markdown file');
}
if (!report.hasJson) {
report.issues.push('Not in JSON');
}
if (report.hasMarkdown && !markdownInfo.hasContent) {
report.issues.push('Markdown file too short');
}
if (!report.hasShortBio) {
report.issues.push('Missing short biography');
}
if (!report.hasLongBio && report.markdownSize > 500) {
report.issues.push('Long biography not extracted');
}
if (report.sectionsCount === 0 && report.markdownSize > 1000) {
report.issues.push('No sections extracted from large file');
}
report.completeness = calculateCompleteness(report);
reports.push(report);
}
report.completeness = calculateCompleteness(report);
reports.push(report);
}
return reports.sort((a, b) => a.id.localeCompare(b.id));
return reports.sort((a, b) => a.id.localeCompare(b.id));
}
function printReport(reports: BiographyReport[]) {
console.log('\n=== Biography Completeness Report ===\n');
console.log('\n=== Biography Completeness Report ===\n');
// Statistics
const total = reports.length;
const complete = reports.filter(r => r.completeness >= 80).length;
const partial = reports.filter(r => r.completeness >= 50 && r.completeness < 80).length;
const incomplete = reports.filter(r => r.completeness < 50).length;
const withIssues = reports.filter(r => r.issues.length > 0);
// Statistics
const total = reports.length;
const complete = reports.filter((r) => r.completeness >= 80).length;
const partial = reports.filter((r) => r.completeness >= 50 && r.completeness < 80).length;
const incomplete = reports.filter((r) => r.completeness < 50).length;
const withIssues = reports.filter((r) => r.issues.length > 0);
console.log(`📊 Statistics:`);
console.log(` Total authors: ${total}`);
console.log(` ✅ Complete (≥80%): ${complete}`);
console.log(` ⚠️ Partial (50-79%): ${partial}`);
console.log(` ❌ Incomplete (<50%): ${incomplete}`);
console.log(` 🔧 With issues: ${withIssues.length}\n`);
console.log(`📊 Statistics:`);
console.log(` Total authors: ${total}`);
console.log(` ✅ Complete (≥80%): ${complete}`);
console.log(` ⚠️ Partial (50-79%): ${partial}`);
console.log(` ❌ Incomplete (<50%): ${incomplete}`);
console.log(` 🔧 With issues: ${withIssues.length}\n`);
// Group by completeness
console.log('=== Detailed Report ===\n');
// Group by completeness
console.log('=== Detailed Report ===\n');
// Show incomplete ones first
const problematic = reports.filter(r => r.completeness < 80 || r.issues.length > 0);
if (problematic.length > 0) {
console.log('⚠️ Authors needing attention:\n');
for (const report of problematic) {
const icon = report.completeness >= 80 ? '✓' :
report.completeness >= 50 ? '⚠' : '✗';
console.log(`${icon} ${report.id} (${report.completeness}%)`);
console.log(` Markdown: ${report.hasMarkdown ? `${report.markdownSize} bytes` : '✗'}`);
console.log(` JSON: ${report.hasJson ? `${report.jsonSize} bytes` : '✗'}`);
if (report.hasJson) {
console.log(` Content:`);
console.log(` - Short bio: ${report.hasShortBio ? '✓' : '✗'}`);
console.log(` - Long bio: ${report.hasLongBio ? '✓' : '✗'}`);
console.log(` - Sections: ${report.sectionsCount}`);
console.log(` - Achievements: ${report.achievementsCount}`);
console.log(` - Famous quote: ${report.hasFamousQuote ? '✓' : '✗'}`);
}
if (report.issues.length > 0) {
console.log(` Issues:`);
report.issues.forEach(issue => console.log(` - ${issue}`));
}
console.log();
}
}
// Show incomplete ones first
const problematic = reports.filter((r) => r.completeness < 80 || r.issues.length > 0);
// Summary of complete ones
const completeAuthors = reports.filter(r => r.completeness >= 80 && r.issues.length === 0);
if (completeAuthors.length > 0) {
console.log(`\n✅ Complete authors (${completeAuthors.length}):`);
const names = completeAuthors.map(r => r.id).join(', ');
console.log(names.length > 200 ? names.substring(0, 200) + '...' : names);
}
if (problematic.length > 0) {
console.log('⚠️ Authors needing attention:\n');
for (const report of problematic) {
const icon = report.completeness >= 80 ? '✓' : report.completeness >= 50 ? '⚠' : '✗';
console.log(`${icon} ${report.id} (${report.completeness}%)`);
console.log(` Markdown: ${report.hasMarkdown ? `${report.markdownSize} bytes` : '✗'}`);
console.log(` JSON: ${report.hasJson ? `${report.jsonSize} bytes` : '✗'}`);
if (report.hasJson) {
console.log(` Content:`);
console.log(` - Short bio: ${report.hasShortBio ? '✓' : '✗'}`);
console.log(` - Long bio: ${report.hasLongBio ? '✓' : '✗'}`);
console.log(` - Sections: ${report.sectionsCount}`);
console.log(` - Achievements: ${report.achievementsCount}`);
console.log(` - Famous quote: ${report.hasFamousQuote ? '✓' : '✗'}`);
}
if (report.issues.length > 0) {
console.log(` Issues:`);
report.issues.forEach((issue) => console.log(` - ${issue}`));
}
console.log();
}
}
// Summary of complete ones
const completeAuthors = reports.filter((r) => r.completeness >= 80 && r.issues.length === 0);
if (completeAuthors.length > 0) {
console.log(`\n✅ Complete authors (${completeAuthors.length}):`);
const names = completeAuthors.map((r) => r.id).join(', ');
console.log(names.length > 200 ? names.substring(0, 200) + '...' : names);
}
}
async function main() {
const reports = analyzeAllBiographies();
printReport(reports);
const reports = analyzeAllBiographies();
printReport(reports);
// Create a detailed JSON report
const reportPath = path.join(process.cwd(), 'biography-report.json');
fs.writeFileSync(reportPath, JSON.stringify(reports, null, 2), 'utf-8');
console.log(`\n📄 Detailed report saved to: biography-report.json`);
// Create a detailed JSON report
const reportPath = path.join(process.cwd(), 'biography-report.json');
fs.writeFileSync(reportPath, JSON.stringify(reports, null, 2), 'utf-8');
console.log(`\n📄 Detailed report saved to: biography-report.json`);
// Return exit code based on completeness
const incompleteCount = reports.filter(r => r.completeness < 50).length;
if (incompleteCount > 10) {
console.log('\n❌ Many biographies are incomplete. Please review and fix.');
process.exit(1);
} else if (incompleteCount > 0) {
console.log('\n⚠ Some biographies need attention.');
process.exit(0);
} else {
console.log('\n✅ All biographies are complete!');
process.exit(0);
}
// Return exit code based on completeness
const incompleteCount = reports.filter((r) => r.completeness < 50).length;
if (incompleteCount > 10) {
console.log('\n❌ Many biographies are incomplete. Please review and fix.');
process.exit(1);
} else if (incompleteCount > 0) {
console.log('\n⚠ Some biographies need attention.');
process.exit(0);
} else {
console.log('\n✅ All biographies are complete!');
process.exit(0);
}
}
main().catch(console.error);
main().catch(console.error);

Some files were not shown because too many files have changed in this diff Show more