mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 10:23:38 +02:00
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:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>© 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>© 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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]`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import React from 'react';
|
|||
import AppleStyleOnboarding from '~/components/onboarding/AppleStyleOnboarding';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
return <AppleStyleOnboarding />;
|
||||
}
|
||||
return <AppleStyleOnboarding />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
2
apps/quote/apps/mobile/nativewind-env.d.ts
vendored
2
apps/quote/apps/mobile/nativewind-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue