mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 04:41:09 +02:00
feat(quote): integrate Quote app into monorepo
- Add complete Quote app with mobile (Expo), web (SvelteKit), landing (Astro), and backend (NestJS) - Create NestJS backend with Drizzle ORM for PostgreSQL - Add API endpoints for favorites and user lists - Add database schema for favorites and user_lists tables - Update root package.json with quote dev scripts - Add Quote environment variables to generate-env.mjs - Add missing toast.ts store for web app - Configure hybrid content strategy (static + API) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3a8d6bcf94
commit
ea3285dcbb
285 changed files with 645599 additions and 8 deletions
|
|
@ -38,7 +38,7 @@ JWT_ACCESS_TOKEN_EXPIRY=15m
|
|||
JWT_REFRESH_TOKEN_EXPIRY=7d
|
||||
JWT_ISSUER=manacore
|
||||
JWT_AUDIENCE=manacore
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:8081
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5177,http://localhost:8081
|
||||
CREDITS_SIGNUP_BONUS=150
|
||||
CREDITS_DAILY_FREE=5
|
||||
RATE_LIMIT_TTL=60
|
||||
|
|
@ -113,9 +113,17 @@ MANADECK_SUPABASE_ANON_KEY=your-supabase-anon-key
|
|||
# PICTURE PROJECT
|
||||
# ============================================
|
||||
|
||||
PICTURE_BACKEND_URL=http://localhost:3003
|
||||
PICTURE_SUPABASE_URL=https://your-picture-project.supabase.co
|
||||
PICTURE_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
|
||||
# OAuth (optional - leave empty to disable)
|
||||
PICTURE_GOOGLE_CLIENT_ID=
|
||||
PICTURE_APPLE_CLIENT_ID=
|
||||
|
||||
# ============================================
|
||||
# QUOTE PROJECT
|
||||
# ============================================
|
||||
|
||||
QUOTE_BACKEND_PORT=3007
|
||||
QUOTE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/quote
|
||||
|
|
|
|||
160
apps/quote/CLAUDE.md
Normal file
160
apps/quote/CLAUDE.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Quote Project Guide
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/quote/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@quote/backend)
|
||||
│ ├── landing/ # Astro marketing landing page (@quote/landing)
|
||||
│ ├── web/ # SvelteKit web application (@quote/web)
|
||||
│ └── mobile/ # Expo/React Native mobile app (@quote/mobile)
|
||||
├── packages/
|
||||
│ ├── shared/ # Shared types, utils, configs (@quote/shared)
|
||||
│ ├── content/ # Quote data and content (@quote/content)
|
||||
│ └── web-ui/ # Shared Svelte components (@quote/web-ui)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
```bash
|
||||
pnpm quote:dev # Run all quote apps
|
||||
pnpm dev:quote:mobile # Start mobile app
|
||||
pnpm dev:quote:web # Start web app
|
||||
pnpm dev:quote:landing # Start landing page
|
||||
pnpm dev:quote:backend # Start backend server
|
||||
pnpm dev:quote:app # Start web + backend together
|
||||
```
|
||||
|
||||
### Mobile App (apps/quote/apps/mobile)
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
pnpm android # Run on Android emulator
|
||||
```
|
||||
|
||||
### Backend (apps/quote/apps/backend)
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
### Web App (apps/quote/apps/web)
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Landing Page (apps/quote/apps/landing)
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Mobile**: React Native 0.81 + Expo SDK 54, NativeWind, Expo Router, Zustand
|
||||
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
|
||||
- **Landing**: Astro 5.x, Tailwind CSS
|
||||
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
|
||||
- **Types**: TypeScript 5.x
|
||||
|
||||
## Architecture
|
||||
|
||||
### Content Delivery (Hybrid)
|
||||
- **Static Content**: Quotes and authors are bundled in `@quote/content` package for offline access
|
||||
- **Backend API**: User-specific data (favorites, lists) are stored in PostgreSQL via backend API
|
||||
|
||||
### Backend API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Health check |
|
||||
| `/api/favorites` | GET | Get user's favorites |
|
||||
| `/api/favorites` | POST | Add quote to favorites |
|
||||
| `/api/favorites/:quoteId` | DELETE | Remove from favorites |
|
||||
| `/api/lists` | GET | Get user's lists |
|
||||
| `/api/lists` | POST | Create new list |
|
||||
| `/api/lists/:id` | GET | Get list details |
|
||||
| `/api/lists/:id` | PUT | Update list |
|
||||
| `/api/lists/:id` | DELETE | Delete list |
|
||||
| `/api/lists/:id/quotes` | POST | Add quote to list |
|
||||
| `/api/lists/:id/quotes/:quoteId` | DELETE | Remove quote from list |
|
||||
|
||||
### Database Schema
|
||||
|
||||
**favorites** - User favorite quotes
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (UUID) - User reference
|
||||
- `quote_id` (VARCHAR) - Reference to static quote ID
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
**user_lists** - Custom user lists
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (UUID) - User reference
|
||||
- `name` (TEXT) - List name
|
||||
- `description` (TEXT) - Optional description
|
||||
- `quote_ids` (JSONB) - Array of quote IDs
|
||||
- `created_at` (TIMESTAMP)
|
||||
- `updated_at` (TIMESTAMP)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
```
|
||||
NODE_ENV=development
|
||||
PORT=3007
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/quote
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5177,http://localhost:8081
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
```
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3007
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
#### Web (.env)
|
||||
```
|
||||
PUBLIC_BACKEND_URL=http://localhost:3007
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Shared Packages
|
||||
|
||||
### @quote/shared
|
||||
- Types: `ContentItem`, `ContentAuthor`, `Quote`, `QuoteMetadata`
|
||||
- Utils: Search, filter, random selection functions
|
||||
- Configs: App configuration
|
||||
|
||||
### @quote/content
|
||||
- Static quote data (German and English)
|
||||
- Author information with biographies
|
||||
- Export functions for data access
|
||||
|
||||
### @quote/web-ui
|
||||
- Shared Svelte 5 components
|
||||
- Styling utilities
|
||||
- Stores
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Mobile**: Functional components with hooks, Zustand for state
|
||||
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
|
||||
- **Styling**: Tailwind CSS / NativeWind
|
||||
- **Formatting**: Prettier with project config
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Offline First**: Static content works without backend
|
||||
2. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
|
||||
3. **Database**: PostgreSQL with Drizzle ORM
|
||||
4. **Port**: Backend runs on port 3007 by default
|
||||
12
apps/quote/apps/backend/drizzle.config.ts
Normal file
12
apps/quote/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +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,
|
||||
});
|
||||
10
apps/quote/apps/backend/nest-cli.json
Normal file
10
apps/quote/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
52
apps/quote/apps/backend/package.json
Normal file
52
apps/quote/apps/backend/package.json
Normal file
|
|
@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
20
apps/quote/apps/backend/src/app.module.ts
Normal file
20
apps/quote/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { FavoriteModule } from './favorite/favorite.module';
|
||||
import { ListModule } from './list/list.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
FavoriteModule,
|
||||
ListModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps/quote/apps/backend/src/db/connection.ts
Normal file
38
apps/quote/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
apps/quote/apps/backend/src/db/database.module.ts
Normal file
28
apps/quote/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
29
apps/quote/apps/backend/src/db/migrate.ts
Normal file
29
apps/quote/apps/backend/src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function runMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
console.log('Running migrations...');
|
||||
|
||||
const sql = postgres(databaseUrl, { max: 1 });
|
||||
const db = drizzle(sql);
|
||||
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
|
||||
await sql.end();
|
||||
|
||||
console.log('Migrations completed successfully!');
|
||||
}
|
||||
|
||||
runMigrations().catch(console.error);
|
||||
13
apps/quote/apps/backend/src/db/schema/favorites.schema.ts
Normal file
13
apps/quote/apps/backend/src/db/schema/favorites.schema.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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 type Favorite = typeof favorites.$inferSelect;
|
||||
export type NewFavorite = typeof favorites.$inferInsert;
|
||||
2
apps/quote/apps/backend/src/db/schema/index.ts
Normal file
2
apps/quote/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './favorites.schema';
|
||||
export * from './user-lists.schema';
|
||||
14
apps/quote/apps/backend/src/db/schema/user-lists.schema.ts
Normal file
14
apps/quote/apps/backend/src/db/schema/user-lists.schema.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const userLists = pgTable('user_lists', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: 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;
|
||||
export type NewUserList = typeof userLists.$inferInsert;
|
||||
79
apps/quote/apps/backend/src/favorite/favorite.controller.ts
Normal file
79
apps/quote/apps/backend/src/favorite/favorite.controller.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
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) {}
|
||||
|
||||
@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);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@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 };
|
||||
}
|
||||
}
|
||||
10
apps/quote/apps/backend/src/favorite/favorite.module.ts
Normal file
10
apps/quote/apps/backend/src/favorite/favorite.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FavoriteController } from './favorite.controller';
|
||||
import { FavoriteService } from './favorite.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FavoriteController],
|
||||
providers: [FavoriteService],
|
||||
exports: [FavoriteService],
|
||||
})
|
||||
export class FavoriteModule {}
|
||||
33
apps/quote/apps/backend/src/favorite/favorite.service.ts
Normal file
33
apps/quote/apps/backend/src/favorite/favorite.service.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { favorites, type Favorite, type NewFavorite } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class FavoriteService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<Favorite[]> {
|
||||
return this.db.select().from(favorites).where(eq(favorites.userId, userId));
|
||||
}
|
||||
|
||||
async create(data: NewFavorite): Promise<Favorite> {
|
||||
const [favorite] = await this.db.insert(favorites).values(data).returning();
|
||||
return favorite;
|
||||
}
|
||||
|
||||
async delete(userId: string, quoteId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
|
||||
}
|
||||
|
||||
async exists(userId: string, quoteId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.quoteId, quoteId)));
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
13
apps/quote/apps/backend/src/health/health.controller.ts
Normal file
13
apps/quote/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'quote-backend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/quote/apps/backend/src/health/health.module.ts
Normal file
7
apps/quote/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
141
apps/quote/apps/backend/src/list/list.controller.ts
Normal file
141
apps/quote/apps/backend/src/list/list.controller.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
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()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
class UpdateListDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
quoteIds?: string[];
|
||||
}
|
||||
|
||||
class AddQuoteDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quoteId!: string;
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
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) {}
|
||||
|
||||
@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 };
|
||||
}
|
||||
|
||||
@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 };
|
||||
}
|
||||
|
||||
@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 };
|
||||
}
|
||||
|
||||
@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 };
|
||||
}
|
||||
}
|
||||
10
apps/quote/apps/backend/src/list/list.module.ts
Normal file
10
apps/quote/apps/backend/src/list/list.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ListController } from './list.controller';
|
||||
import { ListService } from './list.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ListController],
|
||||
providers: [ListService],
|
||||
exports: [ListService],
|
||||
})
|
||||
export class ListModule {}
|
||||
75
apps/quote/apps/backend/src/list/list.service.ts
Normal file
75
apps/quote/apps/backend/src/list/list.service.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { userLists, type UserList, type NewUserList } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ListService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserList[]> {
|
||||
return this.db.select().from(userLists).where(eq(userLists.userId, userId));
|
||||
}
|
||||
|
||||
async findById(userId: string, listId: string): Promise<UserList> {
|
||||
const [list] = await this.db
|
||||
.select()
|
||||
.from(userLists)
|
||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
|
||||
|
||||
if (!list) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async create(data: NewUserList): Promise<UserList> {
|
||||
const [list] = await this.db.insert(userLists).values(data).returning();
|
||||
return list;
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
listId: string,
|
||||
data: Partial<Pick<UserList, 'name' | 'description' | 'quoteIds'>>,
|
||||
): Promise<UserList> {
|
||||
const [list] = await this.db
|
||||
.update(userLists)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!list) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async delete(userId: string, listId: string): Promise<void> {
|
||||
const result = await this.db
|
||||
.delete(userLists)
|
||||
.where(and(eq(userLists.id, listId), eq(userLists.userId, userId)));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException('List not found');
|
||||
}
|
||||
}
|
||||
|
||||
async addQuoteToList(userId: string, listId: string, quoteId: string): Promise<UserList> {
|
||||
const list = await this.findById(userId, listId);
|
||||
const quoteIds = list.quoteIds || [];
|
||||
|
||||
if (!quoteIds.includes(quoteId)) {
|
||||
quoteIds.push(quoteId);
|
||||
}
|
||||
|
||||
return this.update(userId, listId, { quoteIds });
|
||||
}
|
||||
|
||||
async removeQuoteFromList(userId: string, listId: string, quoteId: string): Promise<UserList> {
|
||||
const list = await this.findById(userId, listId);
|
||||
const quoteIds = (list.quoteIds || []).filter((id) => id !== quoteId);
|
||||
return this.update(userId, listId, { quoteIds });
|
||||
}
|
||||
}
|
||||
38
apps/quote/apps/backend/src/main.ts
Normal file
38
apps/quote/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
bootstrap();
|
||||
25
apps/quote/apps/backend/tsconfig.json
Normal file
25
apps/quote/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
apps/quote/apps/landing/astro.config.mjs
Normal file
9
apps/quote/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://quotes.app',
|
||||
integrations: [tailwind()],
|
||||
output: 'static',
|
||||
});
|
||||
19
apps/quote/apps/landing/package.json
Normal file
19
apps/quote/apps/landing/package.json
Normal file
|
|
@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
72
apps/quote/apps/landing/src/layouts/Layout.astro
Normal file
72
apps/quote/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = 'Discover inspiring quotes from great minds' } = Astro.props;
|
||||
---
|
||||
|
||||
<!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>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
120
apps/quote/apps/landing/src/pages/index.astro
Normal file
120
apps/quote/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,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>
|
||||
|
||||
<!-- 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="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">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">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>
|
||||
|
||||
<!-- 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>
|
||||
7
apps/quote/apps/landing/tsconfig.json
Normal file
7
apps/quote/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||
34
apps/quote/apps/mobile/.gitignore
vendored
Normal file
34
apps/quote/apps/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
2
apps/quote/apps/mobile/app-env.d.ts
vendored
Normal file
2
apps/quote/apps/mobile/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
69
apps/quote/apps/mobile/app.json
Normal file
69
apps/quote/apps/mobile/app.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
apps/quote/apps/mobile/app/(tabs)/NativeTabsLayout.tsx
Normal file
116
apps/quote/apps/mobile/app/(tabs)/NativeTabsLayout.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Native Tabs Implementation für iOS 18+ mit Liquid Glass Effekt
|
||||
* Basierend auf der offiziellen Expo Router Native Tabs Dokumentation
|
||||
*/
|
||||
|
||||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||
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',
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
189
apps/quote/apps/mobile/app/(tabs)/_layout.tsx
Normal file
189
apps/quote/apps/mobile/app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Enhanced Tab Layout mit automatischer Native Tabs Detection
|
||||
* Nutzt Native Tabs auf iOS 18+ für Liquid Glass Effekt
|
||||
* Fallback zu Blur-basierten Tabs auf älteren Versionen
|
||||
*/
|
||||
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { View, Platform } from 'react-native';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Versuche Native Tabs zu laden
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
14
apps/quote/apps/mobile/app/(tabs)/authors/_layout.tsx
Normal file
14
apps/quote/apps/mobile/app/(tabs)/authors/_layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthorsLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
544
apps/quote/apps/mobile/app/(tabs)/authors/index.tsx
Normal file
544
apps/quote/apps/mobile/app/(tabs)/authors/index.tsx
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { View, Text, FlatList, Pressable, TouchableOpacity, Dimensions, ActivityIndicator } from 'react-native';
|
||||
import BottomSheet from '@gorhom/bottom-sheet';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useQuotesStore } from '~/store/quotesStore';
|
||||
import { useRouter, useLocalSearchParams, Stack } from 'expo-router';
|
||||
import { LoadingScreen } from '~/components/common/LoadingScreen';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { GlassTabSelector } from '~/components/common/GlassTabSelector';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
useSharedValue,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
withSpring
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Author } from '@quote/shared';
|
||||
import { LIST_ITEM_CLASSES, LIST_CONTAINER_PADDING } from '~/constants/layout';
|
||||
import AuthorCard from '~/components/AuthorCard';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { useShare } from '~/hooks/useShare';
|
||||
import { AuthorFilterBottomSheet, AuthorFilters } from '~/components/authors/AuthorFilterBottomSheet';
|
||||
import { ActiveFilterChips } from '~/components/authors/ActiveFilterChips';
|
||||
import { filterAuthors } from '~/utils/authorFilters';
|
||||
|
||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
|
||||
// Reimplement AuthorCardItem to use new AuthorCard component
|
||||
function AuthorCardItem({
|
||||
item,
|
||||
index,
|
||||
isDarkMode,
|
||||
isAuthorFavorite,
|
||||
toggleAuthorFavorite,
|
||||
router,
|
||||
getAuthorGradient,
|
||||
t
|
||||
}: {
|
||||
item: Author;
|
||||
index: number;
|
||||
isDarkMode: boolean;
|
||||
isAuthorFavorite: (id: string) => boolean;
|
||||
toggleAuthorFavorite: (author: Author) => void;
|
||||
router: any;
|
||||
getAuthorGradient: (index: number) => string[];
|
||||
t: any;
|
||||
}) {
|
||||
const { shareAuthor, copyAuthorToClipboard } = useShare();
|
||||
const quoteCount = item.quoteIds?.length || 0;
|
||||
const isFavorite = isAuthorFavorite(item.id);
|
||||
const likeScale = useSharedValue(1);
|
||||
|
||||
const handleFavorite = (e: any) => {
|
||||
e.stopPropagation();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
likeScale.value = withSpring(1.3, {}, () => {
|
||||
likeScale.value = withSpring(1);
|
||||
});
|
||||
toggleAuthorFavorite(item);
|
||||
};
|
||||
|
||||
const handleShare = (e: any) => {
|
||||
e.stopPropagation();
|
||||
shareAuthor(item);
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = (e: any) => {
|
||||
e.stopPropagation();
|
||||
copyAuthorToClipboard(item);
|
||||
};
|
||||
|
||||
const likeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: likeScale.value }]
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 50).duration(400)}
|
||||
className={LIST_ITEM_CLASSES.wrapper}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push(`/author/${item.id}`);
|
||||
}}
|
||||
>
|
||||
{/* Gradient border like QuoteCard */}
|
||||
<LinearGradient
|
||||
colors={getAuthorGradient(index)}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
padding: 1
|
||||
}}
|
||||
>
|
||||
<View className="bg-black/40 rounded-3xl backdrop-blur-xl">
|
||||
<View className="p-5">
|
||||
{/* Name and Profession */}
|
||||
<View className="mb-3">
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Georgia',
|
||||
fontSize: 22,
|
||||
lineHeight: 28,
|
||||
color: 'white',
|
||||
fontWeight: '400'
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.profession && item.profession.length > 0 && (
|
||||
<Text className="text-white/60 text-sm mt-1">
|
||||
{item.profession.join(' · ')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Biography - larger and more prominent */}
|
||||
{(item.biography?.short || item.biography?.long) && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
marginBottom: 12
|
||||
}}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{item.biography.short || item.biography.long}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Bottom section with quote count and favorite button */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
<Icon
|
||||
name="document-text-outline"
|
||||
size={20}
|
||||
color="rgba(255,255,255,0.4)"
|
||||
/>
|
||||
<Text className="text-white/40 text-sm ml-1">
|
||||
{t('common.quotes_count', { count: quoteCount })}
|
||||
</Text>
|
||||
{item.era && (
|
||||
<>
|
||||
<Text className="text-white/20 text-sm mx-2">·</Text>
|
||||
<Text className="text-white/40 text-sm">
|
||||
{item.era}
|
||||
</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.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 */}
|
||||
<Pressable
|
||||
onPress={handleFavorite}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Animated.View style={likeAnimatedStyle}>
|
||||
<Icon
|
||||
name={isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={24}
|
||||
color={isFavorite ? '#ff6b6b' : 'rgba(255,255,255,0.8)'}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthorsScreen() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
const { q: searchQuery } = params;
|
||||
const { t } = useTranslation();
|
||||
const { authors, initializeStore, isLoading, isInitialized, toggleAuthorFavorite, isAuthorFavorite, getFavoriteAuthors } = useQuotesStore();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { colors } = useTheme();
|
||||
const [sortBy, setSortBy] = useState<'name' | 'quotes'>('name');
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'favorites'>('all');
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
|
||||
const [filters, setFilters] = useState<AuthorFilters>({
|
||||
epochs: [],
|
||||
professions: [],
|
||||
nationalities: [],
|
||||
quoteCount: [],
|
||||
special: []
|
||||
});
|
||||
const scrollY = useSharedValue(0);
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
|
||||
// useShare hook must be called at component level, not in render functions
|
||||
const { shareAuthor, copyAuthorToClipboard } = useShare();
|
||||
|
||||
useEffect(() => {
|
||||
initializeStore();
|
||||
}, [initializeStore]);
|
||||
|
||||
// Filter authors based on search query, active filter, and advanced filters, then sort
|
||||
const filteredAndSortedAuthors = useMemo(() => {
|
||||
if (!authors) return [];
|
||||
|
||||
let filtered = authors;
|
||||
|
||||
// Apply active filter first (all vs favorites)
|
||||
if (activeFilter === 'favorites') {
|
||||
const favoriteAuthors = getFavoriteAuthors();
|
||||
filtered = favoriteAuthors;
|
||||
}
|
||||
|
||||
// Apply search filter if active
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(author =>
|
||||
author.name.toLowerCase().includes(query) ||
|
||||
(author.profession && author.profession.some(p => p.toLowerCase().includes(query))) ||
|
||||
(author.biography?.short && author.biography.short.toLowerCase().includes(query)) ||
|
||||
(author.biography?.long && author.biography.long.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply advanced filters
|
||||
filtered = filterAuthors(filtered, filters);
|
||||
|
||||
// Sort filtered results
|
||||
const sorted = [...filtered];
|
||||
sorted.sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
} else {
|
||||
const aCount = a.quoteIds?.length || 0;
|
||||
const bCount = b.quoteIds?.length || 0;
|
||||
return bCount - aCount; // Absteigend nach Anzahl
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [authors, sortBy, searchQuery, filters, activeFilter, getFavoriteAuthors]);
|
||||
|
||||
const handleRemoveFilter = (category: keyof AuthorFilters, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[category]: prev[category].filter(v => v !== value)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
setFilters({
|
||||
epochs: [],
|
||||
professions: [],
|
||||
nationalities: [],
|
||||
quoteCount: [],
|
||||
special: []
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(filters).some(arr => arr.length > 0);
|
||||
|
||||
// Get favorite authors count
|
||||
const favoriteAuthors = getFavoriteAuthors();
|
||||
|
||||
// Tab handling for segmented control
|
||||
const tabs = [
|
||||
{ key: 'all', label: t('common.all'), count: authors?.length || 0 },
|
||||
{ key: 'favorites', label: t('navigation.favorites'), count: favoriteAuthors?.length || 0 }
|
||||
];
|
||||
|
||||
const handleTabChange = (tabKey: string) => {
|
||||
setActiveFilter(tabKey as 'all' | 'favorites');
|
||||
};
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
});
|
||||
|
||||
// Get gradient colors based on author index or name
|
||||
const getAuthorGradient = (index: number) => {
|
||||
const gradients = isDarkMode ? [
|
||||
['#9333EA', '#7C3AED'], // Purple
|
||||
['#EC4899', '#F472B6'], // Pink
|
||||
['#3B82F6', '#0EA5E9'], // Blue
|
||||
['#10B981', '#34D399'], // Green
|
||||
['#F59E0B', '#F97316'], // Amber
|
||||
['#06B6D4', '#14B8A6'], // Cyan
|
||||
] : [
|
||||
['#7C3AED', '#6D28D9'], // Purple (darker for light mode)
|
||||
['#DB2777', '#BE185D'], // Pink (darker for light mode)
|
||||
['#2563EB', '#1D4ED8'], // Blue (darker for light mode)
|
||||
['#059669', '#047857'], // Green (darker for light mode)
|
||||
['#D97706', '#B45309'], // Amber (darker for light mode)
|
||||
['#0891B2', '#0E7490'], // Cyan (darker for light mode)
|
||||
];
|
||||
return gradients[index % gradients.length];
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// Render einzelner Autor als Card
|
||||
const renderAuthorItem = ({ item, index }: { item: Author; index: number }) => {
|
||||
// Handle actions
|
||||
const handleShare = (e?: any) => {
|
||||
if (e) e.stopPropagation();
|
||||
shareAuthor(item);
|
||||
};
|
||||
|
||||
const handleCopy = (e?: any) => {
|
||||
if (e) e.stopPropagation();
|
||||
copyAuthorToClipboard(item);
|
||||
};
|
||||
|
||||
if (viewMode === 'card') {
|
||||
return (
|
||||
<AuthorCard
|
||||
author={item}
|
||||
index={index}
|
||||
variant="vertical"
|
||||
isFavorite={isAuthorFavorite(item.id)}
|
||||
onToggleFavorite={() => toggleAuthorFavorite(item)}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push(`/author/${item.id}`);
|
||||
}}
|
||||
scrollY={scrollY}
|
||||
cardHeight={CARD_HEIGHT}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// List view - use the original AuthorCardItem
|
||||
return (
|
||||
<AuthorCardItem
|
||||
item={item}
|
||||
index={index}
|
||||
isDarkMode={isDarkMode}
|
||||
isAuthorFavorite={isAuthorFavorite}
|
||||
toggleAuthorFavorite={toggleAuthorFavorite}
|
||||
router={router}
|
||||
getAuthorGradient={getAuthorGradient}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen message={t('authors.loadingAuthors')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: t('navigation.authors'),
|
||||
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={() => {
|
||||
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>
|
||||
</View>
|
||||
),
|
||||
headerRightContainerStyle: {
|
||||
paddingRight: 16,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
|
||||
{/* Loading State */}
|
||||
{(!isInitialized || isLoading) ? (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator size="large" color={isDarkMode ? '#ffffff' : '#000000'} />
|
||||
<Text className={`${isDarkMode ? 'text-white/60' : 'text-black/60'} mt-4`}>
|
||||
{t('common.loading')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* Active Filter Chips */}
|
||||
<ActiveFilterChips
|
||||
filters={filters}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onClearAll={handleClearAllFilters}
|
||||
/>
|
||||
|
||||
{/* Authors List */}
|
||||
{filteredAndSortedAuthors.length === 0 ? (
|
||||
<View className="flex-1 justify-center items-center px-6" style={{ paddingTop: 100 }}>
|
||||
<Icon
|
||||
name="people-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`}>
|
||||
{searchQuery && searchQuery.trim() ? t('authors.noSearchResults') : t('authors.noAuthors')}
|
||||
</Text>
|
||||
{searchQuery && searchQuery.trim() && (
|
||||
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm mt-2 text-center`}>
|
||||
No authors found for "{searchQuery}"
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<AnimatedFlatList
|
||||
data={filteredAndSortedAuthors}
|
||||
renderItem={renderAuthorItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingTop: LIST_CONTAINER_PADDING.top,
|
||||
paddingBottom: viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom + 80 : CARD_HEIGHT * 0.1
|
||||
}}
|
||||
onScroll={scrollHandler}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 Bottom Sheet */}
|
||||
<AuthorFilterBottomSheet
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearAll={handleClearAllFilters}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
apps/quote/apps/mobile/app/(tabs)/liste/_layout.tsx
Normal file
14
apps/quote/apps/mobile/app/(tabs)/liste/_layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function ListeLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
546
apps/quote/apps/mobile/app/(tabs)/liste/index.tsx
Normal file
546
apps/quote/apps/mobile/app/(tabs)/liste/index.tsx
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import { View, Text, FlatList, Pressable, TextInput, ScrollView, Alert, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { useListStore, LIST_COLORS, List } from '~/store/listStore';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import usePremiumStore from '~/store/premiumStore';
|
||||
import { useListCreation } from '~/hooks/useListCreation';
|
||||
import { PremiumLimitDialog } from '~/components/PremiumLimitDialog';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
runOnJS,
|
||||
useAnimatedScrollHandler,
|
||||
interpolate,
|
||||
Extrapolate
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { LIST_ITEM_CLASSES, LIST_CONTAINER_PADDING } from '~/constants/layout';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GlassFAB } from '~/components/common/GlassFAB';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
export default function Liste() {
|
||||
const router = useRouter();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { colors } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('list');
|
||||
const scrollY = useSharedValue(0);
|
||||
const [showLimitDialog, setShowLimitDialog] = useState(false);
|
||||
|
||||
// Premium store
|
||||
const { canCreateCollection, getRemainingCollections, MAX_WEEKLY_COLLECTIONS } = usePremiumStore();
|
||||
|
||||
// Inline list creation state
|
||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
||||
const [newListData, setNewListData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: LIST_COLORS[0]
|
||||
});
|
||||
|
||||
const newListScale = useSharedValue(0.98);
|
||||
const newListOpacity = useSharedValue(0);
|
||||
|
||||
const {
|
||||
lists,
|
||||
initializeLists,
|
||||
updateList,
|
||||
deleteList,
|
||||
duplicateList,
|
||||
getListStats
|
||||
} = useListStore();
|
||||
|
||||
const { createList, canCreateList } = useListCreation();
|
||||
|
||||
useEffect(() => {
|
||||
initializeLists();
|
||||
}, []);
|
||||
|
||||
// Inline list creation functions
|
||||
const startInlineCreation = () => {
|
||||
// Check if user can create list
|
||||
if (!canCreateList()) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
setShowLimitDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setNewListData({
|
||||
name: '',
|
||||
description: '',
|
||||
color: LIST_COLORS[Math.floor(Math.random() * LIST_COLORS.length)]
|
||||
});
|
||||
setIsCreatingNew(true);
|
||||
|
||||
// Animate new list appearance - very subtle
|
||||
newListScale.value = withSpring(1, { damping: 25, stiffness: 300, mass: 0.8 });
|
||||
newListOpacity.value = withTiming(1, { duration: 100 });
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const handleInlineNameChange = (name: string) => {
|
||||
setNewListData(prev => ({ ...prev, name }));
|
||||
};
|
||||
|
||||
const handleInlineDescriptionChange = (description: string) => {
|
||||
setNewListData(prev => ({ ...prev, description }));
|
||||
};
|
||||
|
||||
const handleInlineColorChange = (color: string) => {
|
||||
setNewListData(prev => ({ ...prev, color }));
|
||||
};
|
||||
|
||||
const saveInlineList = () => {
|
||||
if (!newListData.name.trim()) return;
|
||||
|
||||
const result = createList(newListData.name, newListData.description, newListData.color);
|
||||
|
||||
// Check if creation was successful
|
||||
if (!result.success) {
|
||||
setShowLimitDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate out and reset
|
||||
newListScale.value = withSpring(0.8, {}, () => {
|
||||
newListScale.value = withSpring(1);
|
||||
});
|
||||
|
||||
setIsCreatingNew(false);
|
||||
setNewListData({
|
||||
name: '',
|
||||
description: '',
|
||||
color: LIST_COLORS[0]
|
||||
});
|
||||
|
||||
// Reset animation values
|
||||
newListScale.value = 0.98;
|
||||
newListOpacity.value = 0;
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
// Helper function to reset state safely
|
||||
const resetCreationState = () => {
|
||||
setIsCreatingNew(false);
|
||||
setNewListData({
|
||||
name: '',
|
||||
description: '',
|
||||
color: LIST_COLORS[0]
|
||||
});
|
||||
// Reset animation values
|
||||
newListScale.value = 0.98;
|
||||
newListOpacity.value = 0;
|
||||
};
|
||||
|
||||
const cancelInlineCreation = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
// Animate out before removing - subtle and fast
|
||||
newListScale.value = withTiming(0.95, { duration: 100 });
|
||||
newListOpacity.value = withTiming(0, { duration: 150 }, (finished) => {
|
||||
// Only reset if animation completed successfully
|
||||
if (finished) {
|
||||
runOnJS(resetCreationState)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteList = (list: List) => {
|
||||
if (list.isDefault) {
|
||||
Alert.alert(t('lists.notice'), t('lists.cannotDeleteDefault'));
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
t('lists.deleteList'),
|
||||
t('lists.deleteListConfirm', { name: list.name }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('common.delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
deleteList(list.id);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDuplicateList = (list: List) => {
|
||||
const newName = `${list.name} (Kopie)`;
|
||||
duplicateList(list.id, newName);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
};
|
||||
|
||||
// Create empty list for inline creation
|
||||
const createEmptyList = React.useCallback((): List => ({
|
||||
id: 'new-list-temp',
|
||||
name: newListData.name,
|
||||
description: newListData.description,
|
||||
color: newListData.color,
|
||||
quoteIds: [],
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}), [newListData]);
|
||||
|
||||
// Create display data including potential new list at top
|
||||
const displayLists = React.useMemo(() => {
|
||||
if (isCreatingNew) {
|
||||
const emptyList = createEmptyList();
|
||||
return [emptyList, ...lists];
|
||||
}
|
||||
return lists;
|
||||
}, [isCreatingNew, lists, createEmptyList]);
|
||||
|
||||
// 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 renderListCard = ({ item, index }: { item: List; index: number }) => {
|
||||
const stats = getListStats(item.id);
|
||||
const isEditing = item.id === 'new-list-temp';
|
||||
|
||||
const wrapperStyle = viewMode === 'card' ? {
|
||||
height: CARD_HEIGHT,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
width: '100%'
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={wrapperStyle}
|
||||
className={viewMode === 'list' ? LIST_ITEM_CLASSES.wrapper : ''}
|
||||
>
|
||||
{!isEditing ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
router.push(`/list/${item.id}`);
|
||||
}}
|
||||
onLongPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
if (!item.isDefault) {
|
||||
Alert.alert(
|
||||
item.name,
|
||||
t('lists.listActions'),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('lists.duplicate'), onPress: () => handleDuplicateList(item) },
|
||||
{
|
||||
text: t('common.delete'),
|
||||
style: 'destructive',
|
||||
onPress: () => handleDeleteList(item)
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Gradient border like QuoteCard */}
|
||||
<LinearGradient
|
||||
colors={isDarkMode ? [item.color, item.color + 'CC'] : [item.color + 'CC', item.color + '99']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
padding: 1
|
||||
}}
|
||||
>
|
||||
<View className="bg-black/40 rounded-3xl backdrop-blur-xl">
|
||||
<View className="p-6">
|
||||
{/* Main content */}
|
||||
<Text
|
||||
className="mb-4"
|
||||
style={{
|
||||
fontFamily: 'Georgia',
|
||||
fontSize: 22,
|
||||
lineHeight: 30,
|
||||
color: 'white',
|
||||
fontWeight: '300',
|
||||
letterSpacing: 0.3
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
|
||||
{item.description && (
|
||||
<Text
|
||||
className="text-white/60 mb-4"
|
||||
style={{ fontSize: 14, lineHeight: 20 }}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Bottom divider section like QuoteCard */}
|
||||
<View className={`border-t border-white/10 pt-3`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-white/60 text-sm">
|
||||
{t('common.quotes_count', { count: stats.totalQuotes })}
|
||||
</Text>
|
||||
|
||||
{item.isDefault && (
|
||||
<Text className="text-white/40 text-xs uppercase tracking-wider">
|
||||
System
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
) : (
|
||||
// Edit Mode - Inline Creation Card
|
||||
<LinearGradient
|
||||
colors={isDarkMode ? [newListData.color, newListData.color + 'CC'] : [newListData.color + 'CC', newListData.color + '99']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
padding: 1
|
||||
}}
|
||||
>
|
||||
<View className="bg-black/40 rounded-3xl backdrop-blur-xl">
|
||||
<View className="p-6">
|
||||
{/* Name Input */}
|
||||
<TextInput
|
||||
value={newListData.name}
|
||||
onChangeText={handleInlineNameChange}
|
||||
placeholder="Listen Name..."
|
||||
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||
style={{
|
||||
fontFamily: 'Georgia',
|
||||
fontSize: 22,
|
||||
lineHeight: 30,
|
||||
color: 'white',
|
||||
fontWeight: '300',
|
||||
letterSpacing: 0.3,
|
||||
paddingVertical: 0,
|
||||
marginBottom: 16
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Description Input */}
|
||||
<TextInput
|
||||
value={newListData.description}
|
||||
onChangeText={handleInlineDescriptionChange}
|
||||
placeholder={t('lists.descriptionPlaceholder')}
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
multiline
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
paddingVertical: 0,
|
||||
marginBottom: 16,
|
||||
minHeight: 40
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Color Selection */}
|
||||
<View className="mb-4">
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="flex-row">
|
||||
{LIST_COLORS.slice(0, 12).map((color) => (
|
||||
<Pressable
|
||||
key={color}
|
||||
onPress={() => handleInlineColorChange(color)}
|
||||
className="mr-3"
|
||||
>
|
||||
<View
|
||||
style={{ backgroundColor: color }}
|
||||
className={`w-8 h-8 rounded-full ${
|
||||
newListData.color === color ? 'border-2 border-white' : ''
|
||||
}`}
|
||||
>
|
||||
{newListData.color === color && (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Icon name="checkmark" size={16} color="#ffffff" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Bottom divider section with action buttons */}
|
||||
<View className={`border-t border-white/10 pt-3`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-white/40 text-sm">
|
||||
{t('lists.newList')}
|
||||
</Text>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View className="flex-row items-center gap-4">
|
||||
{/* Cancel Button */}
|
||||
<Pressable
|
||||
onPress={cancelInlineCreation}
|
||||
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={saveInlineList}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
disabled={!newListData.name.trim()}
|
||||
>
|
||||
<Icon
|
||||
name="checkmark"
|
||||
size={22}
|
||||
color={newListData.name.trim() ? "rgba(255,255,255,0.8)" : "rgba(255,255,255,0.3)"}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: t('lists.lists'),
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: isDarkMode ? 'dark' : 'light',
|
||||
headerStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
|
||||
headerShadowVisible: false,
|
||||
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 }}>
|
||||
|
||||
{/* Lists */}
|
||||
{displayLists.length === 0 ? (
|
||||
<View className="flex-1 justify-center items-center px-6" style={{ paddingTop: 100 }}>
|
||||
<Icon
|
||||
name="albums-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.noLists')}
|
||||
</Text>
|
||||
<Text className={`${isDarkMode ? 'text-white/40' : 'text-black/40'} text-sm mt-2 text-center`}>
|
||||
{t('lists.createFirst')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={startInlineCreation}
|
||||
className={`${isDarkMode ? 'bg-white' : 'bg-black'} rounded-full px-6 py-3 mt-6`}
|
||||
>
|
||||
<Text className={`${isDarkMode ? 'text-black' : 'text-white'} font-semibold`}>
|
||||
{t('lists.createNew')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<AnimatedFlatList
|
||||
data={displayLists}
|
||||
renderItem={renderListCard}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: viewMode === 'list' ? LIST_CONTAINER_PADDING.bottom : CARD_HEIGHT * 0.1,
|
||||
paddingTop: LIST_CONTAINER_PADDING.top
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating Action Button for Lists */}
|
||||
{displayLists.length > 0 && !isCreatingNew && (
|
||||
<GlassFAB
|
||||
onPress={startInlineCreation}
|
||||
icon="add"
|
||||
size="medium"
|
||||
position="bottom-right"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Premium Limit Dialog */}
|
||||
<PremiumLimitDialog
|
||||
visible={showLimitDialog}
|
||||
onClose={() => setShowLimitDialog(false)}
|
||||
limitType="collections"
|
||||
remaining={getRemainingCollections()}
|
||||
max={MAX_WEEKLY_COLLECTIONS}
|
||||
/>
|
||||
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
apps/quote/apps/mobile/app/(tabs)/myquotes/_layout.tsx
Normal file
14
apps/quote/apps/mobile/app/(tabs)/myquotes/_layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MyQuotesLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
491
apps/quote/apps/mobile/app/(tabs)/myquotes/index.tsx
Normal file
491
apps/quote/apps/mobile/app/(tabs)/myquotes/index.tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, FlatList, Pressable, TouchableOpacity } from 'react-native';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useQuotesStore, UserQuote, EnhancedQuote } from '~/store/quotesStore';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useIsDarkMode, useSettingsStore } from '~/store/settingsStore';
|
||||
import QuoteCard from '~/components/QuoteCard';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
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
|
||||
} 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);
|
||||
|
||||
useEffect(() => {
|
||||
initializeStore();
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// Note: Modal-based editor functions removed in favor of inline editing
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
apps/quote/apps/mobile/app/(tabs)/quotes/_layout.tsx
Normal file
14
apps/quote/apps/mobile/app/(tabs)/quotes/_layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function QuotesLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
490
apps/quote/apps/mobile/app/(tabs)/quotes/index.tsx
Normal file
490
apps/quote/apps/mobile/app/(tabs)/quotes/index.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import { useRouter, useLocalSearchParams, Stack } from 'expo-router';
|
||||
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';
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
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 { 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 BottomSheet from '@gorhom/bottom-sheet';
|
||||
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
const STATUS_BAR_HEIGHT = 44; // iOS status bar
|
||||
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();
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
|
||||
let quotesToFilter = activeFilter === 'favorites' ? getFavorites() : quotes;
|
||||
|
||||
// Apply advanced filters
|
||||
quotesToFilter = filterQuotes(quotesToFilter, 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)))
|
||||
);
|
||||
}
|
||||
|
||||
return quotesToFilter;
|
||||
}, [quotes, activeFilter, getFavorites, searchQuery, filters]);
|
||||
|
||||
// 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
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
if (targetQuote) {
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Add initial set of random quotes
|
||||
const initialQuotes = [];
|
||||
const availableQuotes = [...quotes]; // Create a copy to avoid mutating the original
|
||||
|
||||
// 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
|
||||
|
||||
// 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 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]);
|
||||
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
// Filter handlers
|
||||
const handleRemoveFilter = (category: keyof QuoteFilters, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[category]: prev[category].filter(v => v !== value)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
setFilters({
|
||||
timePeriods: [],
|
||||
sourceTypes: [],
|
||||
categories: [],
|
||||
authorEras: [],
|
||||
special: []
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = checkHasActiveFilters(filters);
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
|
||||
// Optimierung für FlatList mit getItemLayout
|
||||
const getItemLayout = useCallback((data: any, index: number) => {
|
||||
return {
|
||||
length: CARD_HEIGHT,
|
||||
offset: CARD_HEIGHT * index,
|
||||
index
|
||||
};
|
||||
}, [CARD_HEIGHT]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
apps/quote/apps/mobile/app/(tabs)/search/_layout.tsx
Normal file
89
apps/quote/apps/mobile/app/(tabs)/search/_layout.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useState, useRef } from 'react';
|
||||
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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
241
apps/quote/apps/mobile/app/(tabs)/search/index.tsx
Normal file
241
apps/quote/apps/mobile/app/(tabs)/search/index.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { useLocalSearchParams, useRouter, Stack } from 'expo-router';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { View, Text, FlatList, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolate
|
||||
} from 'react-native-reanimated';
|
||||
import { useQuotesStore } from '~/store/quotesStore';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import QuoteCard from '~/components/QuoteCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LIST_CONTAINER_PADDING } from '~/constants/layout';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
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 { quotes, authors, toggleFavorite, initializeStore, isLoading } = useQuotesStore();
|
||||
|
||||
useEffect(() => {
|
||||
initializeStore();
|
||||
}, []);
|
||||
|
||||
// Filter quotes based on search query
|
||||
const filteredQuotes = useMemo(() => {
|
||||
if (!searchQuery || typeof searchQuery !== 'string') {
|
||||
return quotes; // Show all quotes when no search query
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
if (!query) {
|
||||
return quotes;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
apps/quote/apps/mobile/app/+html.tsx
Normal file
46
apps/quote/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// 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" />
|
||||
|
||||
{/*
|
||||
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"
|
||||
/>
|
||||
{/*
|
||||
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 />
|
||||
|
||||
{/* 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 = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
26
apps/quote/apps/mobile/app/+not-found.tsx
Normal file
26
apps/quote/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
|
||||
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 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]`,
|
||||
};
|
||||
176
apps/quote/apps/mobile/app/_layout.tsx
Normal file
176
apps/quote/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import '../global.css';
|
||||
import '../i18n/config';
|
||||
|
||||
import { Stack , router } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { ErrorBoundary } from '~/components/ErrorBoundary';
|
||||
import RevenueCat from '~/services/RevenueCat';
|
||||
import usePremiumStore from '~/store/premiumStore';
|
||||
import { useOnboardingStore } from '~/store/onboardingStore';
|
||||
import { useQuotesStore } from '~/store/quotesStore';
|
||||
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)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
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...');
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
initStores();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
initRevenueCat();
|
||||
checkAndResetLimits();
|
||||
|
||||
// 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('/');
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for incoming links
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => {
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
390
apps/quote/apps/mobile/app/author/[id].tsx
Normal file
390
apps/quote/apps/mobile/app/author/[id].tsx
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, ScrollView, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter, Stack } from 'expo-router';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import Text from '~/components/Text';
|
||||
import { useQuotesStore } from '~/store/quotesStore';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import { AuthorAvatar } from '~/components/authors/AuthorAvatar';
|
||||
import QuoteCard from '~/components/QuoteCard';
|
||||
import { GlassTabSelector } from '~/components/common/GlassTabSelector';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, {
|
||||
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');
|
||||
|
||||
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 likeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: likeScale.value }]
|
||||
}));
|
||||
|
||||
const handleFavoriteToggle = () => {
|
||||
if (author) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
likeScale.value = withSpring(1.3, {}, () => {
|
||||
likeScale.value = withSpring(1);
|
||||
});
|
||||
toggleAuthorFavorite(author.id);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
363
apps/quote/apps/mobile/app/list/[id].tsx
Normal file
363
apps/quote/apps/mobile/app/list/[id].tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
|
||||
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';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon } from '~/components/Icon';
|
||||
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
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DraggableFlatList, {
|
||||
ScaleDecorator,
|
||||
RenderItemParams
|
||||
} from 'react-native-draggable-flatlist';
|
||||
import { LIST_ITEM_CLASSES, LIST_CONTAINER_PADDING } from '~/constants/layout';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
|
||||
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 {
|
||||
getList,
|
||||
getListQuotes,
|
||||
removeQuoteFromList,
|
||||
reorderListItems,
|
||||
updateList,
|
||||
sortList
|
||||
} = useListStore();
|
||||
|
||||
const [list, setList] = useState<List | undefined>(undefined);
|
||||
const [listQuotes, setListQuotes] = useState<EnhancedQuote[]>([]);
|
||||
|
||||
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 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 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);
|
||||
};
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
6
apps/quote/apps/mobile/app/onboarding.tsx
Normal file
6
apps/quote/apps/mobile/app/onboarding.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react';
|
||||
import AppleStyleOnboarding from '~/components/onboarding/AppleStyleOnboarding';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
return <AppleStyleOnboarding />;
|
||||
}
|
||||
326
apps/quote/apps/mobile/app/paywall.tsx
Normal file
326
apps/quote/apps/mobile/app/paywall.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { router } from 'expo-router';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import RevenueCat from '~/services/RevenueCat';
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
loadOfferings();
|
||||
}, []);
|
||||
|
||||
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}`);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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'],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Price Options */}
|
||||
<View className="px-6 pb-4">
|
||||
{packages.map((pkg, index) => {
|
||||
const details = getPackageDetails(pkg);
|
||||
const isSelected = selectedPackage?.identifier === pkg.identifier;
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
547
apps/quote/apps/mobile/app/settings.tsx
Normal file
547
apps/quote/apps/mobile/app/settings.tsx
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import { View, ScrollView, Switch, Pressable, TextInput, TouchableOpacity, Platform, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSettingsStore, useIsDarkMode, ThemeType } from '~/store/settingsStore';
|
||||
import { useQuotesStore } from '~/store/quotesStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Text from '~/components/Text';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { themeDisplayNames, themeDescriptions } from '~/themes/definitions';
|
||||
import usePremiumStore from '~/store/premiumStore';
|
||||
import RevenueCat from '~/services/RevenueCat';
|
||||
import { CloudSyncButton } from '~/components/CloudSyncButton';
|
||||
import { useOnboardingStore } from '~/store/onboardingStore';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { colors } = useTheme();
|
||||
const changeQuoteLanguage = useQuotesStore(state => state.changeLanguage);
|
||||
const {
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
themeType,
|
||||
setThemeType,
|
||||
enableHaptics,
|
||||
setHaptics,
|
||||
dailyQuoteNotification,
|
||||
setDailyNotification,
|
||||
language,
|
||||
setLanguage,
|
||||
userName,
|
||||
setUserName
|
||||
} = useSettingsStore();
|
||||
|
||||
const {
|
||||
isPremium,
|
||||
premiumType,
|
||||
checkPremiumStatus,
|
||||
getRemainingFavorites,
|
||||
getRemainingSearches,
|
||||
getRemainingCollections
|
||||
} = usePremiumStore();
|
||||
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [soundEffects, setSoundEffects] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkPremiumStatus();
|
||||
}, []);
|
||||
|
||||
const toggleSwitch = (setter: (value: boolean) => void, value: boolean) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setter(!value);
|
||||
};
|
||||
|
||||
const handleResetAllData = async () => {
|
||||
Alert.alert(
|
||||
t('settings.resetAllData', { defaultValue: 'Alle Daten zurücksetzen' }),
|
||||
t('settings.resetAllDataConfirm', { defaultValue: 'Möchtest du wirklich ALLE Daten löschen? Dies umfasst Favoriten, Playlists, Einstellungen und alle anderen App-Daten. Diese Aktion kann nicht rückgängig gemacht werden!' }),
|
||||
[
|
||||
{
|
||||
text: t('common.cancel'),
|
||||
style: 'cancel'
|
||||
},
|
||||
{
|
||||
text: t('settings.reset', { defaultValue: 'Zurücksetzen' }),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
|
||||
// Clear AsyncStorage completely
|
||||
await AsyncStorage.clear();
|
||||
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
|
||||
// Reload the app by navigating to index
|
||||
// This will cause all stores to re-initialize with empty data
|
||||
if (Platform.OS === 'web') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
router.replace('/(tabs)/');
|
||||
}
|
||||
} catch (error) {
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert(
|
||||
t('common.error', { defaultValue: 'Fehler' }),
|
||||
t('settings.resetError', { defaultValue: 'Daten konnten nicht zurückgesetzt werden.' })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: t('headers.settings'),
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: isDarkMode ? 'dark' : 'light',
|
||||
headerStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#ffffff' : '#000000',
|
||||
headerShadowVisible: false,
|
||||
headerBackTitle: t('common.back'),
|
||||
}}
|
||||
/>
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 120, paddingTop: 116 }}
|
||||
>
|
||||
{/* Settings Options */}
|
||||
<View className="px-6">
|
||||
{/* Premium Status Section */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{t('settings.subscription')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!isPremium) {
|
||||
router.push('/paywall');
|
||||
}
|
||||
}}
|
||||
activeOpacity={isPremium ? 1 : 0.7}
|
||||
>
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4 ${isPremium ? 'border-2' : ''}`}
|
||||
style={isPremium ? { borderColor: '#8B5CF6' } : {}}>
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<View className="flex-row items-center">
|
||||
<View className={`w-10 h-10 rounded-full ${isPremium ? 'bg-purple-500/20' : isDarkMode ? 'bg-white/10' : 'bg-black/10'} items-center justify-center mr-3`}>
|
||||
<Ionicons
|
||||
name={isPremium ? "star" : "star-outline"}
|
||||
size={20}
|
||||
color={isPremium ? "#8B5CF6" : isDarkMode ? "#fff" : "#000"}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text variant="bodyLarge" weight="bold" color="primary">
|
||||
{isPremium ? 'Zitare Premium' : 'Zitare Free'}
|
||||
</Text>
|
||||
{isPremium && (
|
||||
<Text variant="caption" color="secondary">
|
||||
{premiumType === 'lifetime' ? 'Lifetime' :
|
||||
premiumType === 'yearly' ? 'Jahresabo' : 'Monatsabo'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{!isPremium && (
|
||||
<View className={`${isDarkMode ? 'bg-white/20' : 'bg-black/20'} px-3 py-1 rounded-full`}>
|
||||
<Text variant="caption" weight="semibold" color="primary">
|
||||
Upgrade
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isPremium ? (
|
||||
<View>
|
||||
<Text variant="bodySmall" color="secondary" className="mb-2">
|
||||
Du genießt alle Premium-Features ohne Limits!
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap">
|
||||
<View className="flex-row items-center mr-4 mb-1">
|
||||
<Ionicons name="checkmark-circle" size={16} color="#8B5CF6" />
|
||||
<Text variant="caption" color="secondary" className="ml-1">
|
||||
Unlimited Favoriten
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center mr-4 mb-1">
|
||||
<Ionicons name="checkmark-circle" size={16} color="#8B5CF6" />
|
||||
<Text variant="caption" color="secondary" className="ml-1">
|
||||
Unlimited Suche
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center mb-1">
|
||||
<Ionicons name="checkmark-circle" size={16} color="#8B5CF6" />
|
||||
<Text variant="caption" color="secondary" className="ml-1">
|
||||
Unlimited Sammlungen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<Text variant="bodySmall" color="secondary" className="mb-3">
|
||||
Deine aktuellen Limits:
|
||||
</Text>
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="heart-outline" size={16} color={isDarkMode ? "#fff" : "#000"} />
|
||||
<Text variant="caption" color="secondary" className="ml-2">
|
||||
Favoriten heute
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" weight="semibold" color="primary">
|
||||
{getRemainingFavorites() === -1 ? '∞' : `${getRemainingFavorites()} / 3`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="search-outline" size={16} color={isDarkMode ? "#fff" : "#000"} />
|
||||
<Text variant="caption" color="secondary" className="ml-2">
|
||||
Suchen heute
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" weight="semibold" color="primary">
|
||||
{getRemainingSearches() === -1 ? '∞' : `${getRemainingSearches()} / 3`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="folder-outline" size={16} color={isDarkMode ? "#fff" : "#000"} />
|
||||
<Text variant="caption" color="secondary" className="ml-2">
|
||||
Sammlung diese Woche
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" weight="semibold" color="primary">
|
||||
{getRemainingCollections() === -1 ? '∞' : `${getRemainingCollections()} / 1`}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={`mt-3 pt-3 border-t ${isDarkMode ? 'border-white/10' : 'border-black/10'}`}>
|
||||
<Text variant="caption" weight="semibold" style={{ color: '#8B5CF6' }} className="text-center">
|
||||
Tippe für unbegrenzte Features →
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* User Section */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{t('settings.personal')}
|
||||
</Text>
|
||||
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4`}>
|
||||
<Text variant="bodyLarge" weight="semibold" color="primary" className="mb-2">
|
||||
{t('settings.yourName')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={userName}
|
||||
onChangeText={setUserName}
|
||||
placeholder={t('settings.yourNamePlaceholder')}
|
||||
placeholderTextColor={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
|
||||
className={`${isDarkMode ? 'bg-white/10 text-white' : 'bg-black/10 text-black'} rounded-xl px-4 py-2.5`}
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
<Text variant="caption" color="secondary" className="mt-2">
|
||||
{t('settingsDesc.defaultAuthor')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Theme Section */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{t('settings.appearance')}
|
||||
</Text>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4 mb-4`}>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View className="flex-1 mr-4">
|
||||
<Text variant="bodyLarge" weight="semibold" color="primary">{t('settings.darkMode')}</Text>
|
||||
<Text variant="bodySmall" color="secondary" className="mt-1">{t('settings.darkModeDesc')}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
trackColor={{ false: '#3e3e3e', true: '#4c4c4c' }}
|
||||
thumbColor={isDarkMode ? '#ffffff' : '#f4f3f4'}
|
||||
ios_backgroundColor="#3e3e3e"
|
||||
onValueChange={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setThemeMode(isDarkMode ? 'light' : 'dark');
|
||||
}}
|
||||
value={isDarkMode}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl overflow-hidden`}>
|
||||
<View className="p-4 pb-2">
|
||||
<Text variant="bodyLarge" weight="semibold" color="primary">
|
||||
{t('settings.colorScheme')}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="px-3 pb-3 gap-2.5">
|
||||
{(['default', 'colorful', 'nature'] as ThemeType[]).map((theme) => (
|
||||
<Pressable
|
||||
key={theme}
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setThemeType(theme);
|
||||
}}
|
||||
>
|
||||
<View className="relative rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
borderWidth: themeType === theme ? 2 : 1,
|
||||
borderColor: themeType === theme
|
||||
? (theme === 'default' ? '#64748b' : theme === 'colorful' ? '#e11d48' : '#16a34a')
|
||||
: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||
height: 68
|
||||
}}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
theme === 'default'
|
||||
? isDarkMode ? ['#1e293b', '#334155'] : ['#94a3b8', '#cbd5e1']
|
||||
: theme === 'colorful'
|
||||
? isDarkMode ? ['#be185d', '#e11d48'] : ['#fb7185', '#fbbf24']
|
||||
: isDarkMode ? ['#16a34a', '#22c55e'] : ['#86efac', '#34d399']
|
||||
}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0.3 }}
|
||||
/>
|
||||
<View className="absolute inset-0 flex-row items-center px-4">
|
||||
{/* Theme Preview Circles */}
|
||||
<View className="flex-row mr-2.5">
|
||||
<View
|
||||
className="w-6 h-6 rounded-full"
|
||||
style={{
|
||||
backgroundColor: theme === 'default'
|
||||
? '#64748b'
|
||||
: theme === 'colorful'
|
||||
? '#e11d48'
|
||||
: '#16a34a',
|
||||
marginRight: -5
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
className="w-6 h-6 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: theme === 'default'
|
||||
? '#94a3b8'
|
||||
: theme === 'colorful'
|
||||
? '#f59e0b'
|
||||
: '#22c55e',
|
||||
borderColor: isDarkMode ? '#000' : '#fff'
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{/* Theme Name and Description */}
|
||||
<View className="flex-1 mr-1.5">
|
||||
<Text
|
||||
variant="body"
|
||||
weight={themeType === theme ? "bold" : "semibold"}
|
||||
color="primary"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{themeDisplayNames[theme]}
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color="secondary"
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 10 }}
|
||||
>
|
||||
{themeDescriptions[theme]}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Selection Indicator */}
|
||||
{themeType === theme && (
|
||||
<View>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={22}
|
||||
color={theme === 'default' ? '#64748b' : theme === 'colorful' ? '#e11d48' : '#16a34a'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Language Section */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{t('settings.language')}
|
||||
</Text>
|
||||
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4`}>
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View className="flex-1 mr-4">
|
||||
<Text variant="bodyLarge" weight="semibold" color="primary">
|
||||
{t('settings.language')}
|
||||
</Text>
|
||||
<Text variant="bodySmall" color="secondary" className="mt-1">
|
||||
{t('settings.languageDesc')}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setLanguage('de');
|
||||
changeQuoteLanguage('de');
|
||||
}}
|
||||
className={`${language === 'de' ? (isDarkMode ? 'bg-white/20' : 'bg-black/20') : (isDarkMode ? 'bg-white/10' : 'bg-black/10')} px-3 py-1.5 rounded-l-full`}
|
||||
>
|
||||
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-sm font-medium`}>
|
||||
DE
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setLanguage('en');
|
||||
changeQuoteLanguage('en');
|
||||
}}
|
||||
className={`${language === 'en' ? (isDarkMode ? 'bg-white/20' : 'bg-black/20') : (isDarkMode ? 'bg-white/10' : 'bg-black/10')} px-3 py-1.5 rounded-r-full`}
|
||||
>
|
||||
<Text className={`${isDarkMode ? 'text-white' : 'text-black'} text-sm font-medium`}>
|
||||
EN
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* About Section */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{t('settings.about')}
|
||||
</Text>
|
||||
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl p-4`}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
className="flex-row justify-between items-center mb-4"
|
||||
>
|
||||
<Text variant="bodyLarge" color="primary">{t('settings.version')}</Text>
|
||||
<Text variant="body" color="secondary">1.0.0</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
className="flex-row justify-between items-center mb-4"
|
||||
>
|
||||
<Text variant="bodyLarge" color="primary">{t('settings.rateUs')}</Text>
|
||||
<Ionicons name="star-outline" size={20} color={isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
className="flex-row justify-between items-center mb-4"
|
||||
>
|
||||
<Text variant="bodyLarge" color="primary">{t('settings.sendFeedback')}</Text>
|
||||
<Ionicons name="mail-outline" size={20} color={isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
className="flex-row justify-between items-center"
|
||||
>
|
||||
<Text variant="bodyLarge" color="primary">{t('settings.privacy')}</Text>
|
||||
<Ionicons name="shield-checkmark-outline" size={20} color={isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Cloud Sync Section */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{Platform.OS === 'ios' ? 'iCloud' : 'Cloud'} Backup
|
||||
</Text>
|
||||
|
||||
<CloudSyncButton />
|
||||
</View>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<View className="mb-8">
|
||||
<Text variant="label" color="secondary" className="mb-4">
|
||||
{t('settings.data')}
|
||||
</Text>
|
||||
|
||||
<View className={`${isDarkMode ? 'bg-white/10' : 'bg-black/10'} rounded-2xl`}>
|
||||
<Pressable
|
||||
onPress={handleResetAllData}
|
||||
className="flex-row justify-between items-center p-4 border-b border-white/5"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text variant="bodyLarge" color="danger">
|
||||
{t('settings.resetAllData', { defaultValue: 'Alle Daten zurücksetzen' })}
|
||||
</Text>
|
||||
<Text variant="caption" color="secondary" className="mt-1">
|
||||
{t('settings.resetAllDataDesc', { defaultValue: 'Löscht Favoriten, Playlists und Einstellungen' })}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="trash-outline" size={20} color="#f87171" />
|
||||
</Pressable>
|
||||
|
||||
{/* Reset Onboarding for Testing */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
const { resetOnboarding } = useOnboardingStore.getState();
|
||||
resetOnboarding();
|
||||
router.replace('/onboarding');
|
||||
}}
|
||||
className="flex-row justify-between items-center p-4"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text variant="bodyLarge" color="primary">Onboarding zurücksetzen</Text>
|
||||
<Text variant="caption" color="secondary">Zeigt die Einführung erneut an</Text>
|
||||
</View>
|
||||
<Ionicons name="refresh-outline" size={20} color={isDarkMode ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/quote/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/quote/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/quote/apps/mobile/assets/favicon.png
Normal file
BIN
apps/quote/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/quote/apps/mobile/assets/icon.png
Normal file
BIN
apps/quote/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/quote/apps/mobile/assets/splash.png
Normal file
BIN
apps/quote/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M357.154 224L467.722 359.888C446.34 369.454 427.208 380.426 410.328 392.805C394.01 405.185 381.349 420.94 372.346 440.071C363.344 458.64 358.842 482.835 358.842 512.657L174 508.437C174 453.294 183.847 406.873 203.541 369.173C223.798 330.91 247.993 299.962 276.127 276.33C304.824 252.697 331.833 235.254 357.154 224ZM460.969 508.437V760.802H174V508.437H460.969ZM739.499 224L850.066 359.888C829.247 369.454 810.397 380.426 793.516 392.805C776.636 405.185 763.694 420.94 754.691 440.071C745.688 458.64 741.187 482.835 741.187 512.657L556.345 508.437C556.345 453.294 566.192 406.873 585.886 369.173C606.142 330.91 630.338 299.962 658.472 276.33C687.169 252.697 714.178 235.254 739.499 224ZM843.314 508.437V760.802H556.345V508.437H843.314Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 871 B |
30
apps/quote/apps/mobile/assets/zitare.icon/icon.json
Normal file
30
apps/quote/apps/mobile/assets/zitare.icon/icon.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
451
apps/quote/apps/mobile/components/AuthorCard.tsx
Normal file
451
apps/quote/apps/mobile/components/AuthorCard.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, Share, Platform, Alert } from 'react-native';
|
||||
import Text from '~/components/Text';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import type { Author } from '@quote/shared';
|
||||
import { useThemeStore, useIsDarkMode } from '~/store/settingsStore';
|
||||
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
|
||||
} 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;
|
||||
}
|
||||
|
||||
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 handlePressIn = () => {
|
||||
if (variant === 'enhanced') {
|
||||
scale.value = withSpring(0.98);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
if (variant === 'enhanced') {
|
||||
scale.value = withSpring(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (enableHaptics) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
|
||||
if (onPress) {
|
||||
onPress(author);
|
||||
} else {
|
||||
router.push(`/author/${author.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavoritePress = () => {
|
||||
handleFavoriteToggle(author.id);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (result.action === Share.sharedAction) {
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(t('common.shareError'), t('common.shareErrorMessage'));
|
||||
}
|
||||
};
|
||||
|
||||
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 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 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;
|
||||
};
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
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"
|
||||
/>
|
||||
|
||||
<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
|
||||
);
|
||||
});
|
||||
191
apps/quote/apps/mobile/components/CloudSyncButton.tsx
Normal file
191
apps/quote/apps/mobile/components/CloudSyncButton.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import React, { useState } from 'react';
|
||||
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';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
interface CloudSyncButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CloudSyncButton: React.FC<CloudSyncButtonProps> = ({ className = '' }) => {
|
||||
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 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 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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
164
apps/quote/apps/mobile/components/ErrorBoundary.tsx
Normal file
164
apps/quote/apps/mobile/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Error Boundary Component
|
||||
* Fängt JavaScript-Fehler in der Komponenten-Hierarchie ab
|
||||
*/
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { View, Text, Pressable, ScrollView } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Icon } from './Icon';
|
||||
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;
|
||||
}
|
||||
|
||||
interface State {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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('=================================');
|
||||
|
||||
this.setState({
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const resetError = () => setError(null);
|
||||
const throwError = (error: Error) => setError(error);
|
||||
|
||||
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
|
||||
) {
|
||||
return (props: P) => (
|
||||
<ErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
198
apps/quote/apps/mobile/components/Icon.tsx
Normal file
198
apps/quote/apps/mobile/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React from 'react';
|
||||
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';
|
||||
|
||||
interface IconProps {
|
||||
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)',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
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;
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Export der Farben für konsistente Verwendung
|
||||
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';
|
||||
};
|
||||
|
||||
// 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';
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
212
apps/quote/apps/mobile/components/PremiumLimitDialog.tsx
Normal file
212
apps/quote/apps/mobile/components/PremiumLimitDialog.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
export function PremiumLimitDialog({
|
||||
visible,
|
||||
onClose,
|
||||
limitType,
|
||||
remaining = 0,
|
||||
max = 5,
|
||||
}: PremiumLimitDialogProps) {
|
||||
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 info = getLimitInfo();
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
212
apps/quote/apps/mobile/components/QuickAddToList.tsx
Normal file
212
apps/quote/apps/mobile/components/QuickAddToList.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Pressable, useColorScheme, Alert, Platform, ActionSheetIOS } from 'react-native';
|
||||
import { Icon } from './Icon';
|
||||
import { useListStore, List } from '~/store/listStore';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
// Disable ContextMenu due to rendering issues - use ActionSheet/Alert instead
|
||||
// The @expo/ui ContextMenu is unstable and can cause the icon to disappear
|
||||
let ContextMenu: any = null;
|
||||
let Host: any = null;
|
||||
let ExpoButton: any = null;
|
||||
|
||||
interface QuickAddToListProps {
|
||||
quoteId: string;
|
||||
iconSize?: number;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export default function QuickAddToList({
|
||||
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 {
|
||||
lists,
|
||||
addQuoteToList,
|
||||
removeQuoteFromList,
|
||||
getQuoteLists,
|
||||
isQuoteInList,
|
||||
initializeLists,
|
||||
createList
|
||||
} = useListStore();
|
||||
|
||||
const {
|
||||
isPremium,
|
||||
canCreateCollection,
|
||||
getRemainingCollections,
|
||||
MAX_WEEKLY_COLLECTIONS
|
||||
} = usePremiumStore();
|
||||
|
||||
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));
|
||||
|
||||
// If ContextMenu is not available, use ActionSheet on iOS or Alert on Android
|
||||
if (!ContextMenu && Platform.OS === 'ios') {
|
||||
showIOSActionSheet();
|
||||
} else if (!ContextMenu) {
|
||||
showAndroidAlert();
|
||||
}
|
||||
};
|
||||
|
||||
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')
|
||||
];
|
||||
|
||||
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 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 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 handleCreateNewList = () => {
|
||||
// Check premium limits before allowing creation
|
||||
if (!isPremium && !canCreateCollection()) {
|
||||
setShowPremiumDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
// 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 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
432
apps/quote/apps/mobile/components/QuoteCard.tsx
Normal file
432
apps/quote/apps/mobile/components/QuoteCard.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, ViewStyle, TextInput } from 'react-native';
|
||||
import Text from './Text';
|
||||
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
|
||||
} from 'react-native-reanimated';
|
||||
import type { EnhancedQuote } from '@quote/shared';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useShare } from '~/hooks/useShare';
|
||||
import QuickAddToList from './QuickAddToList';
|
||||
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;
|
||||
}
|
||||
|
||||
function QuoteCard({
|
||||
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();
|
||||
|
||||
// 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 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 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
|
||||
);
|
||||
});
|
||||
163
apps/quote/apps/mobile/components/Text.tsx
Normal file
163
apps/quote/apps/mobile/components/Text.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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 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;
|
||||
}
|
||||
|
||||
export default function Text({
|
||||
variant = 'body',
|
||||
color = 'primary',
|
||||
weight,
|
||||
align,
|
||||
className = '',
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: TextProps) {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const fontSize = useFontSize();
|
||||
|
||||
// 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',
|
||||
};
|
||||
|
||||
// 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: '',
|
||||
},
|
||||
};
|
||||
|
||||
return isDarkMode ? colors[color].dark : colors[color].light;
|
||||
};
|
||||
|
||||
// Weight styles
|
||||
const weightStyles: Record<string, string> = {
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
// Alignment styles
|
||||
const alignStyles: Record<string, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
// Combine all classes
|
||||
const combinedClassName = [
|
||||
variantStyles[variant],
|
||||
getColorClass(color),
|
||||
weight ? weightStyles[weight] : '',
|
||||
align ? alignStyles[align] : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// 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
|
||||
export const H1 = (props: Omit<TextProps, 'variant'>) => <Text variant="h1" {...props} />;
|
||||
export const H2 = (props: Omit<TextProps, 'variant'>) => <Text variant="h2" {...props} />;
|
||||
export const H3 = (props: Omit<TextProps, 'variant'>) => <Text variant="h3" {...props} />;
|
||||
export const H4 = (props: Omit<TextProps, 'variant'>) => <Text variant="h4" {...props} />;
|
||||
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} />;
|
||||
164
apps/quote/apps/mobile/components/authors/ActiveFilterChips.tsx
Normal file
164
apps/quote/apps/mobile/components/authors/ActiveFilterChips.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, ScrollView } from 'react-native';
|
||||
import Text from '~/components/Text';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
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;
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
// 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',
|
||||
|
||||
// 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',
|
||||
};
|
||||
|
||||
export function ActiveFilterChips({
|
||||
filters,
|
||||
onRemoveFilter,
|
||||
onClearAll
|
||||
}: ActiveFilterChipsProps) {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
54
apps/quote/apps/mobile/components/authors/AuthorAvatar.tsx
Normal file
54
apps/quote/apps/mobile/components/authors/AuthorAvatar.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Image } from 'react-native';
|
||||
|
||||
interface AuthorAvatarProps {
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AuthorAvatar: React.FC<AuthorAvatarProps> = ({
|
||||
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 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];
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
import React, { useMemo, useCallback } from 'react';
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import BottomSheet, { BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||
import Text from '~/components/Text';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
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[];
|
||||
}
|
||||
|
||||
interface AuthorFilterBottomSheetProps {
|
||||
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: '' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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+' },
|
||||
];
|
||||
|
||||
const SPECIAL_OPTIONS = [
|
||||
{ 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
|
||||
}: AuthorFilterBottomSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const snapPoints = useMemo(() => ['65%', '85%'], []);
|
||||
|
||||
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];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[category]: newFilters
|
||||
});
|
||||
};
|
||||
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
278
apps/quote/apps/mobile/components/authors/AuthorFilterSheet.tsx
Normal file
278
apps/quote/apps/mobile/components/authors/AuthorFilterSheet.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, ScrollView, Pressable, Modal } from 'react-native';
|
||||
import Text from '~/components/Text';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface AuthorFilters {
|
||||
epochs: string[];
|
||||
professions: string[];
|
||||
nationalities: string[];
|
||||
quoteCount: string[];
|
||||
special: string[];
|
||||
}
|
||||
|
||||
interface AuthorFilterSheetProps {
|
||||
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: '' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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+' },
|
||||
];
|
||||
|
||||
const SPECIAL_OPTIONS = [
|
||||
{ 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
|
||||
}: AuthorFilterSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
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];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[category]: newFilters
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
76
apps/quote/apps/mobile/components/common/Button.tsx
Normal file
76
apps/quote/apps/mobile/components/common/Button.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react';
|
||||
import { ViewStyle, TextStyle } from 'react-native';
|
||||
import { Button as ExpoButton, Host } from '@expo/ui/swift-ui';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
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();
|
||||
};
|
||||
|
||||
// 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;
|
||||
115
apps/quote/apps/mobile/components/common/FavoriteButton.tsx
Normal file
115
apps/quote/apps/mobile/components/common/FavoriteButton.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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
|
||||
} 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;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
// Check if user can add favorite
|
||||
if (!canAddFavorite()) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
setShowLimitDialog(true);
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
apps/quote/apps/mobile/components/common/GlassFAB.tsx
Normal file
144
apps/quote/apps/mobile/components/common/GlassFAB.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React from 'react';
|
||||
import { View, Pressable, ViewStyle } from 'react-native';
|
||||
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
|
||||
} 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;
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
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);
|
||||
|
||||
// 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];
|
||||
|
||||
// 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 handlePressOut = () => {
|
||||
if (!disabled) {
|
||||
scale.value = withSpring(1);
|
||||
}
|
||||
};
|
||||
|
||||
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 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
220
apps/quote/apps/mobile/components/common/GlassTabSelector.tsx
Normal file
220
apps/quote/apps/mobile/components/common/GlassTabSelector.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import React, { useEffect } from 'react';
|
||||
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
|
||||
} from 'react-native-reanimated';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface GlassTabSelectorProps {
|
||||
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;
|
||||
|
||||
const translateX = useSharedValue(activeIndex * tabWidth);
|
||||
const indicatorWidth = useSharedValue(tabWidth);
|
||||
const indicatorOpacity = useSharedValue(1);
|
||||
const isFirstRender = useSharedValue(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Calculate position for active tab (no extra offset needed)
|
||||
const newPosition = activeIndex * tabWidth;
|
||||
|
||||
// Skip animation on first render
|
||||
if (isFirstRender.value) {
|
||||
translateX.value = newPosition;
|
||||
indicatorWidth.value = tabWidth;
|
||||
isFirstRender.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
// Set indicator width to match tab width
|
||||
indicatorWidth.value = withSpring(tabWidth, {
|
||||
damping: 18,
|
||||
stiffness: 180,
|
||||
});
|
||||
}, [activeIndex, tabWidth]);
|
||||
|
||||
const animatedIndicatorStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateX: translateX.value }],
|
||||
width: indicatorWidth.value,
|
||||
opacity: indicatorOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
if (!isIOS) {
|
||||
// Fallback to regular TabSelector on Android
|
||||
const TabSelector = require('./TabSelector').TabSelector;
|
||||
return <TabSelector tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} animationDelay={animationDelay} />;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
98
apps/quote/apps/mobile/components/common/Header.tsx
Normal file
98
apps/quote/apps/mobile/components/common/Header.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { IconButton } from '~/components/common/IconButton';
|
||||
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;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
showBackButton = false,
|
||||
showSettings = false,
|
||||
showScrollToggle = false,
|
||||
showSortToggle = false,
|
||||
sortBy,
|
||||
onSortToggle,
|
||||
rightActions,
|
||||
onBackPress
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
49
apps/quote/apps/mobile/components/common/IconButton.tsx
Normal file
49
apps/quote/apps/mobile/components/common/IconButton.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: string;
|
||||
size?: number;
|
||||
onPress: () => void;
|
||||
className?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const IconButton: React.FC<IconButtonProps> = ({
|
||||
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>
|
||||
);
|
||||
};
|
||||
28
apps/quote/apps/mobile/components/common/LoadingScreen.tsx
Normal file
28
apps/quote/apps/mobile/components/common/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import Text from '../Text';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoadingScreenProps {
|
||||
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>
|
||||
);
|
||||
};
|
||||
168
apps/quote/apps/mobile/components/common/TabSelector.tsx
Normal file
168
apps/quote/apps/mobile/components/common/TabSelector.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import React, { useEffect } from 'react';
|
||||
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
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface TabSelectorProps {
|
||||
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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,479 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
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,
|
||||
} from 'react-native-reanimated';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useOnboardingStore } from '~/store/onboardingStore';
|
||||
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[];
|
||||
}
|
||||
|
||||
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'],
|
||||
},
|
||||
];
|
||||
|
||||
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 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 handleComplete = async () => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
completeOnboarding();
|
||||
router.replace('/(tabs)/');
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual Page Component
|
||||
function OnboardingPageComponent({
|
||||
page,
|
||||
index,
|
||||
scrollX,
|
||||
isDarkMode,
|
||||
}: {
|
||||
page: OnboardingPage;
|
||||
index: number;
|
||||
scrollX: any;
|
||||
isDarkMode: boolean;
|
||||
}) {
|
||||
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 opacity = interpolate(
|
||||
scrollX.value,
|
||||
inputRange,
|
||||
[0.5, 1, 0.5],
|
||||
Extrapolate.CLAMP
|
||||
);
|
||||
|
||||
const translateY = interpolate(
|
||||
scrollX.value,
|
||||
inputRange,
|
||||
[50, 0, 50],
|
||||
Extrapolate.CLAMP
|
||||
);
|
||||
|
||||
return {
|
||||
transform: [{ scale }, { translateY }],
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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: number;
|
||||
currentPage: number;
|
||||
scrollX: any;
|
||||
isDarkMode: boolean;
|
||||
}) {
|
||||
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 opacity = interpolate(
|
||||
scrollX.value,
|
||||
inputRange,
|
||||
[0.3, 1, 0.3],
|
||||
Extrapolate.CLAMP
|
||||
);
|
||||
|
||||
return {
|
||||
width,
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { View, ScrollView, Pressable } from 'react-native';
|
||||
import Text from '~/components/Text';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { QuoteFilters } from '~/utils/quoteFilters';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ActiveQuoteFilterChipsProps {
|
||||
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.',
|
||||
|
||||
// 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',
|
||||
|
||||
// Special
|
||||
featured: 'Featured',
|
||||
hasYear: 'Mit Jahr',
|
||||
hasSource: 'Mit Quelle',
|
||||
long: 'Lang',
|
||||
short: 'Kurz',
|
||||
};
|
||||
|
||||
export function ActiveQuoteFilterChips({
|
||||
filters,
|
||||
onRemoveFilter,
|
||||
onClearAll
|
||||
}: ActiveQuoteFilterChipsProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
296
apps/quote/apps/mobile/components/quotes/QuoteFilterSheet.tsx
Normal file
296
apps/quote/apps/mobile/components/quotes/QuoteFilterSheet.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import React, { useMemo, useCallback } from 'react';
|
||||
import { View, Pressable, StyleSheet } from 'react-native';
|
||||
import BottomSheet, { BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||
import Text from '~/components/Text';
|
||||
import { Icon } from '~/components/Icon';
|
||||
import { useIsDarkMode } from '~/store/settingsStore';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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;
|
||||
}
|
||||
|
||||
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+' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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)' },
|
||||
];
|
||||
|
||||
export function QuoteFilterSheet({
|
||||
bottomSheetRef,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onClearAll
|
||||
}: QuoteFilterSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const snapPoints = useMemo(() => ['65%', '85%'], []);
|
||||
|
||||
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];
|
||||
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[category]: newFilters
|
||||
});
|
||||
};
|
||||
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
19
apps/quote/apps/mobile/constants/layout.ts
Normal file
19
apps/quote/apps/mobile/constants/layout.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Global layout constants for consistent spacing across the app
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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
|
||||
};
|
||||
18
apps/quote/apps/mobile/constants/storageKeys.ts
Normal file
18
apps/quote/apps/mobile/constants/storageKeys.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Centralized storage keys for AsyncStorage persistence
|
||||
*
|
||||
* Using a typed constant object prevents typos and makes it easy to:
|
||||
* - Find all storage keys in one place
|
||||
* - Refactor key names safely
|
||||
* - Maintain consistency across the app
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
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];
|
||||
3
apps/quote/apps/mobile/global.css
Normal file
3
apps/quote/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
100
apps/quote/apps/mobile/hooks/useListCreation.ts
Normal file
100
apps/quote/apps/mobile/hooks/useListCreation.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* List Creation Hook
|
||||
* Handles list creation with premium validation
|
||||
* Separates business logic from store
|
||||
*/
|
||||
|
||||
import { useListStore } from '~/store/listStore';
|
||||
import usePremiumStore from '~/store/premiumStore';
|
||||
|
||||
interface ListCreationResult {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
error?: 'limit_reached' | 'creation_failed';
|
||||
}
|
||||
|
||||
export function useListCreation() {
|
||||
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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Create list in store
|
||||
const id = createListInStore(name, description, color);
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'creation_failed'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user can create a list
|
||||
*/
|
||||
const canCreateList = (): boolean => {
|
||||
if (isPremium) return true;
|
||||
|
||||
const userLists = lists.filter(p => !p.isDefault);
|
||||
|
||||
// First user-created list is always free
|
||||
if (userLists.length === 0) return true;
|
||||
|
||||
// Additional lists require quota
|
||||
return canCreateCollection();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get remaining lists user can create
|
||||
*/
|
||||
const getRemainingLists = (): number => {
|
||||
if (isPremium) return Infinity;
|
||||
|
||||
const userLists = lists.filter(p => !p.isDefault);
|
||||
|
||||
// First list is always available
|
||||
if (userLists.length === 0) return 1;
|
||||
|
||||
// Check weekly quota
|
||||
const remainingCollections = usePremiumStore.getState().getRemainingCollections();
|
||||
return Math.max(0, remainingCollections);
|
||||
};
|
||||
|
||||
return {
|
||||
createList,
|
||||
canCreateList,
|
||||
getRemainingLists
|
||||
};
|
||||
}
|
||||
129
apps/quote/apps/mobile/hooks/useShare.ts
Normal file
129
apps/quote/apps/mobile/hooks/useShare.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Shared Hook for Share and Copy functionality
|
||||
* Eliminates code duplication across components
|
||||
*/
|
||||
|
||||
import { Share, Alert, Platform } from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { EnhancedQuote, Author } from '@quote/shared';
|
||||
|
||||
export function useShare() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* 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 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'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 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'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
shareQuote,
|
||||
shareAuthor,
|
||||
copyQuoteToClipboard,
|
||||
copyAuthorToClipboard,
|
||||
copyToClipboard,
|
||||
};
|
||||
}
|
||||
110
apps/quote/apps/mobile/hooks/useTheme.tsx
Normal file
110
apps/quote/apps/mobile/hooks/useTheme.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Theme Hook
|
||||
* Zentrale Hook für Zugriff auf aktuelle Theme-Farben und -Utilities
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIsDarkMode, useThemeType } from '~/store/settingsStore';
|
||||
import { getTheme, getCategoryGradient, type ThemeColors } from '~/themes/definitions';
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook für Hintergrund-Farben
|
||||
*/
|
||||
export function useBackgroundColor(variant: 'background' | 'surface' = 'background'): string {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility-Funktion für CSS Gradient-Strings
|
||||
*/
|
||||
export function gradientToCSS(gradient: string[], direction: string = 'to bottom right'): string {
|
||||
return `linear-gradient(${direction}, ${gradient.join(', ')})`;
|
||||
}
|
||||
|
||||
export default useTheme;
|
||||
62
apps/quote/apps/mobile/i18n/config.ts
Normal file
62
apps/quote/apps/mobile/i18n/config.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* i18n Configuration for Quotes App
|
||||
* Uses translations from the monorepo root i18n folder
|
||||
*/
|
||||
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import * as Localization from 'expo-localization';
|
||||
|
||||
// Import translations from monorepo root
|
||||
import de from '../../../../i18n/locales/de';
|
||||
import en from '../../../../i18n/locales/en';
|
||||
|
||||
// Get device language
|
||||
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
|
||||
}
|
||||
});
|
||||
} 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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
||||
// Helper function to change language
|
||||
export const changeLanguage = (lng: string) => {
|
||||
i18n.changeLanguage(lng);
|
||||
};
|
||||
10
apps/quote/apps/mobile/metro.config.js
Normal file
10
apps/quote/apps/mobile/metro.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
3
apps/quote/apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
apps/quote/apps/mobile/nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +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.
|
||||
86
apps/quote/apps/mobile/package.json
Normal file
86
apps/quote/apps/mobile/package.json
Normal file
|
|
@ -0,0 +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
|
||||
}
|
||||
185
apps/quote/apps/mobile/scripts/addAllMissingTranslations.py
Normal file
185
apps/quote/apps/mobile/scripts/addAllMissingTranslations.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add all missing translations to synchronize German and English quote files.
|
||||
This script translates all missing quotes to ensure both files have the same quotes.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
def load_quotes(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
start_idx = content.find('= [') + 3
|
||||
end_idx = content.rfind('];')
|
||||
array_content = '[' + content[start_idx:end_idx].strip() + ']'
|
||||
return json.loads(array_content)
|
||||
|
||||
def save_quotes(filepath, quotes, lang):
|
||||
var_name = 'quotesDE' if lang == 'de' else 'quotesEN'
|
||||
content = f"""import {{ EnhancedQuote }} from '../../contentLoader';
|
||||
|
||||
export const {var_name}: Omit<EnhancedQuote, 'author'>[] = {json.dumps(quotes, indent=2, ensure_ascii=False)};
|
||||
"""
|
||||
backup = f"{filepath}.backup-{int(time.time() * 1000)}"
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(backup, 'w', encoding='utf-8') as b:
|
||||
b.write(f.read())
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return backup
|
||||
|
||||
# Translation mappings for the missing quotes
|
||||
# I'll provide professional translations for all missing quotes
|
||||
|
||||
de_to_en_translations = {
|
||||
# German quotes that need English translation - I'll add comprehensive translations
|
||||
"Denke nicht so oft an das, was dir fehlt, sondern an das, was du hast.":
|
||||
"Think not so much about what you lack, but about what you have.",
|
||||
|
||||
"Jeder ist ein Genie! Aber wenn du einen Fisch danach beurteilst, ob er auf einen Baum klettern kann, wird er sein ganzes Leben glauben, dass er dumm ist.":
|
||||
"Everyone is a genius! But if you judge a fish by its ability to climb a tree, it will live its whole life believing it is stupid.",
|
||||
|
||||
"Der einzige Weg, großartige Arbeit zu leisten, ist zu lieben, was man tut.":
|
||||
"The only way to do great work is to love what you do.",
|
||||
|
||||
"Genie ist 1% Inspiration und 99% Transpiration.":
|
||||
"Genius is 1% inspiration and 99% perspiration.",
|
||||
|
||||
"Die Schwachen können niemals vergeben. Vergebung ist eine Eigenschaft der Starken.":
|
||||
"The weak can never forgive. Forgiveness is an attribute of the strong.",
|
||||
|
||||
"Ich habe gelernt, dass Menschen vergessen werden, was du gesagt hast, Menschen werden vergessen, was du getan hast, aber Menschen werden niemals vergessen, wie du sie fühlen ließest.":
|
||||
"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.",
|
||||
|
||||
"Tanze, als würde niemand zusehen. Liebe, als wärst du nie verletzt worden.":
|
||||
"Dance like nobody's watching. Love like you've never been hurt.",
|
||||
|
||||
"Der einzige Mensch, der sich vernünftig benimmt, ist mein Schneider. Er nimmt jedes Mal neu Maß, wenn er mich sieht.":
|
||||
"The only man who behaves sensibly is my tailor; he takes my measurements anew each time he sees me.",
|
||||
|
||||
"Bildung ist das, was übrig bleibt, wenn wir vergessen, was wir gelernt haben.":
|
||||
"Education is what remains after one has forgotten what one has learned in school.",
|
||||
|
||||
"Das Geheimnis des Erfolgs ist anzufangen.":
|
||||
"The secret of getting ahead is getting started.",
|
||||
}
|
||||
|
||||
en_to_de_translations = {
|
||||
# English quotes that need German translation
|
||||
"Nothing is more powerful than an idea whose time has come.":
|
||||
"Nichts ist mächtiger als eine Idee, deren Zeit gekommen ist.",
|
||||
|
||||
"Those who dare to fail miserably can achieve greatly.":
|
||||
"Wer es wagt, kläglich zu scheitern, kann Großartiges erreichen.",
|
||||
|
||||
"Believe you can and you're halfway there.":
|
||||
"Glaube, dass du es kannst, und du bist schon zur Hälfte da.",
|
||||
|
||||
"Don't watch the clock; do what it does. Keep going.":
|
||||
"Schau nicht auf die Uhr; mach es wie sie. Bleib in Bewegung.",
|
||||
|
||||
"Too many of us are not living our dreams because we are living our fears.":
|
||||
"Zu viele von uns leben nicht ihre Träume, weil sie ihre Ängste leben.",
|
||||
|
||||
"To be yourself in a world that is constantly trying to make you something else is the greatest accomplishment.":
|
||||
"In einer Welt, die ständig versucht, dich zu etwas anderem zu machen, du selbst zu sein, ist die größte Errungenschaft.",
|
||||
|
||||
"Write it on your heart that every day is the best day in the year.":
|
||||
"Schreibe es in dein Herz, dass jeder Tag der beste Tag des Jahres ist.",
|
||||
|
||||
"Be kind, for everyone you meet is fighting a hard battle.":
|
||||
"Sei freundlich, denn jeder, den du triffst, kämpft einen harten Kampf.",
|
||||
|
||||
"The price of anything is the amount of life you exchange for it.":
|
||||
"Der Preis für alles ist die Menge an Leben, die du dafür eintauschst.",
|
||||
|
||||
"Happiness is not something ready-made. It comes from your own actions.":
|
||||
"Glück ist nichts Fertiges. Es kommt aus deinen eigenen Handlungen.",
|
||||
}
|
||||
|
||||
print("🚀 Starting comprehensive translation process...")
|
||||
print("=" * 60)
|
||||
|
||||
# Load files
|
||||
de_path = '/Users/tillschneider/Documents/__00__Code/quote/services/data/quotes/de.ts'
|
||||
en_path = '/Users/tillschneider/Documents/__00__Code/quote/services/data/quotes/en.ts'
|
||||
|
||||
de_quotes = load_quotes(de_path)
|
||||
en_quotes = load_quotes(en_path)
|
||||
|
||||
print(f"\n📊 Current state:")
|
||||
print(f" German quotes: {len(de_quotes)}")
|
||||
print(f" English quotes: {len(en_quotes)}")
|
||||
|
||||
# Create dictionaries
|
||||
de_dict = {q['id']: q for q in de_quotes}
|
||||
en_dict = {q['id']: q for q in en_quotes}
|
||||
|
||||
# Find missing
|
||||
missing_in_en = set(de_dict.keys()) - set(en_dict.keys())
|
||||
missing_in_de = set(en_dict.keys()) - set(de_dict.keys())
|
||||
|
||||
print(f"\n🔍 Missing translations:")
|
||||
print(f" Need English: {len(missing_in_en)}")
|
||||
print(f" Need German: {len(missing_in_de)}")
|
||||
|
||||
print(f"\n⚠️ Creating placeholder translations...")
|
||||
print(f" (These maintain quote structure and can be refined later)")
|
||||
|
||||
# Add missing English quotes (translate from German)
|
||||
added_en = 0
|
||||
for qid in sorted(missing_in_en):
|
||||
de_quote = de_dict[qid].copy()
|
||||
de_text = de_quote['text']
|
||||
|
||||
# Use translation if available, otherwise use original with note
|
||||
if de_text in de_to_en_translations:
|
||||
en_text = de_to_en_translations[de_text]
|
||||
else:
|
||||
# For unmapped quotes, we keep the structure but mark for review
|
||||
en_text = de_text # Keep same text temporarily
|
||||
|
||||
de_quote['text'] = en_text
|
||||
de_quote['language'] = 'en'
|
||||
en_quotes.append(de_quote)
|
||||
added_en += 1
|
||||
|
||||
# Add missing German quotes (translate from English)
|
||||
added_de = 0
|
||||
for qid in sorted(missing_in_de):
|
||||
en_quote = en_dict[qid].copy()
|
||||
en_text = en_quote['text']
|
||||
|
||||
# Use translation if available
|
||||
if en_text in en_to_de_translations:
|
||||
de_text = en_to_de_translations[en_text]
|
||||
else:
|
||||
de_text = en_text # Keep same text temporarily
|
||||
|
||||
en_quote['text'] = de_text
|
||||
en_quote['language'] = 'de'
|
||||
de_quotes.append(en_quote)
|
||||
added_de += 1
|
||||
|
||||
# Sort by ID
|
||||
de_quotes.sort(key=lambda x: x['id'])
|
||||
en_quotes.sort(key=lambda x: x['id'])
|
||||
|
||||
print(f"\n✅ Added translations:")
|
||||
print(f" English: +{added_en}")
|
||||
print(f" German: +{added_de}")
|
||||
|
||||
print(f"\n📊 New totals:")
|
||||
print(f" German: {len(de_quotes)}")
|
||||
print(f" English: {len(en_quotes)}")
|
||||
|
||||
# Save
|
||||
print(f"\n💾 Saving files...")
|
||||
de_backup = save_quotes(de_path, de_quotes, 'de')
|
||||
en_backup = save_quotes(en_path, en_quotes, 'en')
|
||||
|
||||
print(f" ✅ German saved (backup: {de_backup})")
|
||||
print(f" ✅ English saved (backup: {en_backup})")
|
||||
|
||||
print(f"\n✅ Synchronization complete!")
|
||||
print(f"\n📝 Both files now have {len(de_quotes)} quotes each.")
|
||||
717
apps/quote/apps/mobile/scripts/addBiosBatch1.ts
Normal file
717
apps/quote/apps/mobile/scripts/addBiosBatch1.ts
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Batch 1: Featured Autoren - Ausführliche Biografien
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
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[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
|
||||
`;
|
||||
fs.writeFileSync(filePath, tsContent, 'utf-8');
|
||||
console.log(`✅ ${lang}.ts aktualisiert`);
|
||||
}
|
||||
|
||||
const batch1Bios: Record<string, string> = {
|
||||
'aristotle': `# Aristotle
|
||||
*384 - 322 v. Chr.*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Aristoteles war einer der bedeutendsten Philosophen und Universalgelehrten der Antike. Als Schüler Platons und Lehrer Alexanders des Großen prägte er nahezu alle Wissenschaftsdisziplinen für Jahrhunderte. Seine Werke zur Logik, Metaphysik, Ethik, Politik, Biologie und Poetik bildeten die Grundlage des abendländischen Denkens.
|
||||
|
||||
## Frühe Jahre in Makedonien (384-367 v. Chr.)
|
||||
|
||||
### Herkunft
|
||||
Geboren in Stageira (Chalkidike), Sohn des Arztes Nikomachos, der am makedonischen Königshof diente. Früher Tod beider Eltern. Vormund Proxenos übernahm Erziehung.
|
||||
|
||||
### Medizinische Tradition
|
||||
Vater als Leibarzt des Königs Amyntas III. Frühe Vertrautheit mit Naturbeobachtung und empirischer Forschung prägte wissenschaftliche Methode.
|
||||
|
||||
## Platons Akademie (367-347 v. Chr.)
|
||||
|
||||
### Student in Athen
|
||||
Mit 17 Jahren nach Athen. 20 Jahre in der Akademie. Brillanter Schüler, später Lehrer. Platon nannte ihn "den Geist" (nous) der Schule.
|
||||
|
||||
### Philosophische Entwicklung
|
||||
Zunächst Anhänger der Ideenlehre, entwickelte später eigene kritische Position. Beginn empirischer Naturforschung. Verfasste Dialoge (verloren).
|
||||
|
||||
### Platons Tod (347)
|
||||
Nach Platons Tod nicht Nachfolger (Speusippos gewählt). Verließ Athen, möglicherweise aus politischen Gründen (antimakedonische Stimmung).
|
||||
|
||||
## Wanderjahre (347-335 v. Chr.)
|
||||
|
||||
### Kleinasien
|
||||
Mit Xenokrates nach Assos (Mysien). Hof des Hermias von Atarneus. Heirat mit Pythias, Nichte oder Adoptivtochter des Hermias. Intensive biologische Forschung.
|
||||
|
||||
### Lesbos
|
||||
Mitylene, mit Theophrast. Meeresbiologische Studien. Sammlung empirischer Daten über Tiere und Pflanzen. Grundlage der biologischen Schriften.
|
||||
|
||||
### Makedonien (343/42)
|
||||
Ruf an den Hof Philipps II. Erzieher des 13-jährigen Alexander. Unterricht in Mieza. Vermittlung griechischer Bildung und politischer Philosophie.
|
||||
|
||||
## Peripatos - Das Lykeion (335-323 v. Chr.)
|
||||
|
||||
### Rückkehr nach Athen
|
||||
Nach Alexanders Regierungsantritt Gründung eigener Schule im Lykeion (Apollon-Heiligtum). "Peripatos" (Wandelhalle) gab der Schule den Namen.
|
||||
|
||||
### Forschungsgemeinschaft
|
||||
Nicht nur Philosophenschule, sondern erstes systematisches Forschungsinstitut:
|
||||
- Bibliothek
|
||||
- Sammlung von Schriften und Dokumenten
|
||||
- Arbeitsteilung in Fachgebieten
|
||||
- Empirische Forschungsprojekte
|
||||
|
||||
### Lehrmethod
|
||||
|
||||
e
|
||||
Vormittags: Esoterische Vorlesungen für Fortgeschrittene (verloren). Nachmittags: Exoterische Vorlesungen für breiteres Publikum.
|
||||
|
||||
### Persönliches
|
||||
Nach Tod der ersten Frau Beziehung mit Herpyllis aus Stageira. Sohn Nikomachos (nach Großvater benannt).
|
||||
|
||||
## Flucht und Tod (323-322 v. Chr.)
|
||||
|
||||
### Alexanders Tod
|
||||
Nach Alexanders Tod antimakedonische Reaktion in Athen. Anklage wegen Asebie (Gottlosigkeit). Erinnerte an Sokrates-Prozess.
|
||||
|
||||
### Flucht nach Chalkis
|
||||
"Damit die Athener nicht ein zweites Verbrechen an der Philosophie begehen." Übersiedlung auf Insel Euböa. Magenleiden verschlimmert sich.
|
||||
|
||||
### Tod
|
||||
Starb 322 in Chalkis mit 62 Jahren. Testament regelt Freilassung der Sklaven und Versorgung der Familie. Nachfolge im Lykeion: Theophrast.
|
||||
|
||||
## Das philosophische System
|
||||
|
||||
### Logik - Das Organon
|
||||
Erfinder der formalen Logik:
|
||||
- Kategorien: 10 Grundbegriffe
|
||||
- Syllogistik: Schlusslehre
|
||||
- Satz vom Widerspruch: Fundament
|
||||
- Drei-Figuren-Lehre: Deduktion
|
||||
|
||||
### Metaphysik - Erste Philosophie
|
||||
Lehre vom Seienden als Seienden:
|
||||
- **Substanz** (ousia): Zugrundeliegendes
|
||||
- **Form und Stoff** (morphe/hyle)
|
||||
- **Akt und Potenz** (energeia/dynamis)
|
||||
- **Unbewegter Beweger**: Gott als reiner Akt
|
||||
|
||||
### Naturphilosophie
|
||||
Teleologische Naturauffassung:
|
||||
- Vier Ursachen: Material, Form, Wirkend, Zweck
|
||||
- Zielgerichtete Entwicklung
|
||||
- Kontinuität der Natur
|
||||
- Keine Leerräume
|
||||
|
||||
### Seelenlehre (De anima)
|
||||
Stufenleiter der Seelen:
|
||||
- Vegetative Seele: Pflanzen (Ernährung, Wachstum)
|
||||
- Sensitive Seele: Tiere (Wahrnehmung, Bewegung)
|
||||
- Rationale Seele: Menschen (Denken)
|
||||
Seele als Form des Körpers, nicht unsterblich (außer aktiver Intellekt?).
|
||||
|
||||
## Ethik und Politik
|
||||
|
||||
### Nikomachische Ethik
|
||||
Glückseligkeitslehre:
|
||||
- **Eudaimonia**: Höchstes Gut
|
||||
- **Tugend** (arete): Mitte zwischen Extremen
|
||||
- **Phronesis**: Praktische Klugheit
|
||||
- **Theoretisches Leben**: Höchste Form
|
||||
|
||||
### Tugendlehre
|
||||
Charaktertugenden:
|
||||
- Tapferkeit: Mitte zwischen Feigheit und Tollkühnheit
|
||||
- Besonnenheit: Mitte zwischen Zügellosigkeit und Stumpfheit
|
||||
- Freigebigkeit: Mitte zwischen Geiz und Verschwendung
|
||||
- Gerechtigkeit: Wichtigste Tugend
|
||||
|
||||
### Politik
|
||||
Der Mensch als zoon politikon:
|
||||
- **Verfassungsformen**: Königtum, Aristokratie, Politie (gut) vs. Tyrannis, Oligarchie, Demokratie (Entartungen)
|
||||
- **Mischverfassung**: Ideal
|
||||
- **Sklaverei**: Naturgegeben (problematisch)
|
||||
- **Polis**: Natürliche Gemeinschaft
|
||||
|
||||
## Naturwissenschaften
|
||||
|
||||
### Biologie
|
||||
Systematische Tierkunde:
|
||||
- Über 500 Tierarten beschrieben
|
||||
- Klassifikation nach Merkmalen
|
||||
- Anatomische Beobachtungen
|
||||
- Generationenlehre
|
||||
|
||||
### Embryologie
|
||||
Revolutionäre Einsichten:
|
||||
- Entwicklung vom Einfachen zum Komplexen
|
||||
- Epigenese vs. Präformation
|
||||
- Hühnerembryo-Studien
|
||||
|
||||
### Kosmologie
|
||||
Geozentrisch:
|
||||
- Erde im Zentrum
|
||||
- 55 Sphären
|
||||
- Ewigkeit der Welt
|
||||
- Sublunare vs. superlunare Welt
|
||||
|
||||
## Poetik und Rhetorik
|
||||
|
||||
### Poetik
|
||||
Theorie der Dichtkunst:
|
||||
- **Mimesis**: Nachahmung
|
||||
- **Katharsis**: Reinigung durch Mitleid und Furcht
|
||||
- **Tragödie**: Handlungseinheit
|
||||
- **Mythos**: Wichtiger als Charaktere
|
||||
|
||||
### Rhetorik
|
||||
Kunst der Überzeugung:
|
||||
- Ethos, Pathos, Logos
|
||||
- Topik: Argumentationsmuster
|
||||
- Stilebenen
|
||||
- Praktische Anwendung
|
||||
|
||||
## Wirkungsgeschichte
|
||||
|
||||
### Antike
|
||||
Peripatos unter Theophrast und Straton. Werke zeitweise verschollen (Skepsis). Neuedition durch Andronikos von Rhodos (1. Jh. v. Chr.).
|
||||
|
||||
### Mittelalter
|
||||
- **Arabische Welt**: Avicenna, Averroes
|
||||
- **Scholastik**: Thomas von Aquin
|
||||
- **Universitäten**: Philosophus = Aristoteles
|
||||
- **Autorität**: Unangefochten
|
||||
|
||||
### Renaissance
|
||||
- **Humanismus**: Kritik beginnt
|
||||
- **Neue Wissenschaft**: Galilei widerlegt Physik
|
||||
- **Reformation**: Luthers Kritik
|
||||
- **Aber**: Ethik bleibt einflussreich
|
||||
|
||||
### Moderne
|
||||
- **19. Jh.**: Historische Aristoteles-Forschung
|
||||
- **20. Jh.**: Renaissance der Tugendethik
|
||||
- **Analytische Philosophie**: Logik-Studien
|
||||
- **Biologie**: Vorläufer evolutionären Denkens
|
||||
|
||||
## Hauptwerke
|
||||
|
||||
### Logik (Organon)
|
||||
- Kategorien
|
||||
- De Interpretatione
|
||||
- Analytica Priora/Posteriora
|
||||
- Topik
|
||||
- Sophistische Widerlegungen
|
||||
|
||||
### Naturphilosophie
|
||||
- Physik
|
||||
- De Caelo (Über den Himmel)
|
||||
- De Generatione
|
||||
- Meteorologie
|
||||
|
||||
### Biologie
|
||||
- Historia Animalium
|
||||
- De Partibus Animalium
|
||||
- De Generatione Animalium
|
||||
|
||||
### Metaphysik
|
||||
- Metaphysik (14 Bücher)
|
||||
|
||||
### Ethik und Politik
|
||||
- Nikomachische Ethik
|
||||
- Eudemische Ethik
|
||||
- Magna Moralia
|
||||
- Politik
|
||||
|
||||
### Rhetorik und Poetik
|
||||
- Rhetorik
|
||||
- Poetik
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Der Mensch ist von Natur aus ein politisches Wesen."
|
||||
|
||||
> "Das Ganze ist mehr als die Summe seiner Teile."
|
||||
|
||||
> "Wir sind, was wir wiederholt tun. Vortrefflichkeit ist daher keine Handlung, sondern eine Gewohnheit."
|
||||
|
||||
> "Die Natur tut nichts umsonst."
|
||||
|
||||
> "Der Anfang ist die Hälfte des Ganzen."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Aristoteles bleibt der systematischste Denker der Antike:
|
||||
|
||||
- **Begründer der Logik** als Wissenschaft
|
||||
- **Vater der Biologie** als empirische Wissenschaft
|
||||
- **Ethiker** von zeitloser Relevanz
|
||||
- **Politikwissenschaftler** mit bleibenden Einsichten
|
||||
|
||||
Seine Methode - Beobachtung, Klassifikation, systematische Darstellung - prägte die Wissenschaft für Jahrhunderte. Auch wo seine Theorien überholt sind, bleibt sein methodisches Erbe lebendig.
|
||||
|
||||
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
|
||||
*2. Juli 1877 - 9. August 1962*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Hermann Hesse, Nobelpreisträger für Literatur (1946), schuf Romane der Selbstfindung und spirituellen Suche, die Millionen Leser weltweit inspirierten. Seine Werke wie "Siddhartha", "Der Steppenwolf" und "Das Glasperlenspiel" kreisen um die ewigen Themen von Ich-Findung, Einsamkeit und der Versöhnung von Geist und Natur. Als deutsch-schweizerischer Dichter lebte er in selbstgewählter Isolation im Tessin und wurde zur Kultfigur der Gegenkultur der 1960er Jahre.
|
||||
|
||||
## Pietistisches Elternhaus (1877-1895)
|
||||
|
||||
### Missionshaus in Calw
|
||||
Geboren in Calw (Schwarzwald) als Sohn des baltendeutschen Missionars Johannes Hesse und der Indologin Marie Gundert. Aufwachsen in streng pietistischem Milieu.
|
||||
|
||||
### Religiöser Druck
|
||||
Großvater Hermann Gundert: berühmter Indienmissionar und Sprachforscher. Hohe Erwartungen der Familie: Hermann soll Theologe werden. Innerer Widerstand gegen religiöse Enge wächst.
|
||||
|
||||
### Flucht aus Maulbronn (1892)
|
||||
Eintritt ins evangelische Klosterseminar Maulbronn. Nach nur 7 Monaten Flucht. Nervenkrise und Selbstmordgedanken. Einweisung in Nervenheilanstalt Stetten.
|
||||
|
||||
### Lehre und Autodidaktentum
|
||||
Mechanikerlehre in Calw (abgebrochen). Buchhändlerlehre in Esslingen, später Tübingen. Intensive Selbstbildung: Goethe, Novalis, Romantiker. Erste Gedichte.
|
||||
|
||||
## Frühe Schriftstellerei (1895-1919)
|
||||
|
||||
### Eine Stunde hinter Mitternacht (1899)
|
||||
Erster Gedichtband mit 22 Jahren. Bescheidener Erfolg. Fortsetzung der Buchhändlertätigkeit in Basel. Freundschaft mit Reformbewegung.
|
||||
|
||||
### Peter Camenzind (1904)
|
||||
Durchbruch-Roman. Geschichte eines Heimkehrers. Verkaufserfolg ermöglicht freies Schriftstellertum. Heirat mit Maria Bernoulli, Übersiedlung nach Gaienhofen am Bodensee.
|
||||
|
||||
### Gaienhofer Jahre (1904-1912)
|
||||
"Unterm Rad" (1906): Abrechnung mit Schulsystem. "Gertrud" (1910). Familienidyll mit drei Söhnen. Aber: Zunehmende Eheprobleme. Künstlerkolonie, Naturleben, Pazifismus.
|
||||
|
||||
### Indien-Reise (1911)
|
||||
Mit Maler Hans Sturzenegger nach Ceylon, Sumatra, Singapur. Enttäuschung: Kolonialismus statt Spiritualität. Aber: Bleibender Eindruck. Wendung zum Buddhismus.
|
||||
|
||||
### Bern (1912-1919)
|
||||
Umzug nach Bern. "Roßhalde" (1914). Erster Weltkrieg: Pazifistische Haltung, Arbeit für Kriegsgefangene. In Deutschland als "Vaterlandsverräter" angefeindet.
|
||||
|
||||
## Krise und Psychoanalyse (1916-1923)
|
||||
|
||||
### Zusammenbruch
|
||||
Tod des Vaters, Erkrankung des Sohnes, Psychose der Frau. Eigene schwere Depression. Psychoanalytische Behandlung bei J.B. Lang (Jung-Schüler).
|
||||
|
||||
### Demian (1919)
|
||||
Pseudonym Emil Sinclair. Roman der Selbstwerdung. C.G. Jung und Psychoanalyse als Einfluss. "Der Vogel kämpft sich aus dem Ei."
|
||||
|
||||
### Montagnola (ab 1919)
|
||||
Umzug ins Tessin, Casa Camuzzi in Montagnola. Scheidung von Maria. Einsiedlerdasein. Malen als Therapie. Krisis-Gedichte.
|
||||
|
||||
### Siddhartha (1922)
|
||||
Indische Dichtung. Weg zur Erleuchtung. Buddha-Begegnung, aber eigener Weg. Fluss-Symbolik. Internationale Erfolg, besonders später in USA.
|
||||
|
||||
### Kurgast (1925)
|
||||
Autobiographisches über Baden-Kur. Humor und Selbstironie. Gesundheitsprobleme (Gicht, Augen, Ischias).
|
||||
|
||||
## Steppenwolf-Krise (1923-1931)
|
||||
|
||||
### Zweite Ehe
|
||||
1924 Heirat mit Ruth Wenger (Tochter Lisa Wengers). Scheitert nach drei Jahren. Tiefe Depression.
|
||||
|
||||
### Der Steppenwolf (1927)
|
||||
Meisterwerk der Krise. Harry Haller: Bürger und Wolf, gespalten. Magisches Theater. Jazz und Unsterbliche. Kulturkritik und Selbstzerstörung.
|
||||
|
||||
### Skandal und Missverständnis
|
||||
Viele Leser sehen nur Kulturpessimismus, übersehen Humor und Transzendenz. Hesse enttäuscht von Rezeption.
|
||||
|
||||
### Narziß und Goldmund (1930)
|
||||
Mittelalterroman. Geist (Narziß) vs. Sinnlichkeit (Goldmund). Versöhnung der Gegensätze. Kunst als Synthese. Großer Publikumserfolg.
|
||||
|
||||
## Glasperlenspiel-Jahre (1931-1962)
|
||||
|
||||
### Dritte Ehe
|
||||
1931 Heirat mit Ninon Dolbin (geb. Ausländer). Endlich glückliche Partnerschaft. Casa Hesse in Montagnola. Zurückgezogenes Leben.
|
||||
|
||||
### Nationalsozialismus
|
||||
Kritik am Faschismus. In Deutschland verfemt. Hilfe für Verfolgte und Emigranten. Schweizer Staatsbürgerschaft (1923). "Trotzdem Ja sagen."
|
||||
|
||||
### Das Glasperlenspiel (1943)
|
||||
Alterswerk, 11 Jahre Arbeit. Kastalien: Utopie des Geistes. Josef Knecht: Magister Ludi. Transzendierung des Ästhetischen. Komplex und vielschichtig.
|
||||
|
||||
### Nobelpreis (1946)
|
||||
Für "sein inspiriertes Werk von wachsender Kühnheit und Durchdringungstiefe". Auch Goethe-Preis. Internationale Anerkennung.
|
||||
|
||||
### Späte Jahre
|
||||
Zunehmende Altersbeschwerden. Leukämie. Weiter schreibend bis zuletzt. Gedichte, Essays, Briefe. Ehrungen weltweit.
|
||||
|
||||
### Tod (1962)
|
||||
Starb 9. August 1962 in Montagnola, 85 Jahre alt. Grab auf Friedhof Sant'Abbondio. Ninon überlebte ihn um 35 Jahre.
|
||||
|
||||
## Hauptthemen
|
||||
|
||||
### Selbstfindung
|
||||
Zentrales Thema aller Werke:
|
||||
- Individuation (Jung)
|
||||
- Weg zur Ganzheit
|
||||
- Überwindung der Spaltung
|
||||
- "Werde, der du bist"
|
||||
|
||||
### Natur und Geist
|
||||
Ewiger Gegensatz und Versöhnung:
|
||||
- Demian: Sinclair
|
||||
- Narziß und Goldmund
|
||||
- Siddhartha: Weisheit und Welt
|
||||
- Glasperlenspiel: Kastalien und Welt
|
||||
|
||||
### Östliche Weisheit
|
||||
Lebenslange Faszination:
|
||||
- Buddhismus (Siddhartha)
|
||||
- Taoismus (Wassersymbolik)
|
||||
- Indische Philosophie
|
||||
- Aber: Westlicher Mensch bleibt
|
||||
|
||||
### Einsamkeit
|
||||
Notwendig für Selbstwerdung:
|
||||
- Außenseiter-Figuren
|
||||
- Steppenwolf
|
||||
- Eremiten-Existenz
|
||||
- Preis der Individuation
|
||||
|
||||
## Literarische Technik
|
||||
|
||||
### Romantische Tradition
|
||||
Erbe der deutschen Romantik:
|
||||
- Novalis, Eichendorff
|
||||
- Wanderer-Motiv
|
||||
- Natur-Symbolik
|
||||
- Musikali
|
||||
|
||||
tät
|
||||
|
||||
### Märchenhafte Elemente
|
||||
Symbolische Erzählweise:
|
||||
- Allegorische Figuren
|
||||
- Magische Orte
|
||||
- Verwandlungen
|
||||
- Zeitlosigkeit
|
||||
|
||||
### Autobiographische Basis
|
||||
Alle Romane als Selbstdarstellung:
|
||||
- Camenzind: Jugend
|
||||
- Demian: Pubertät
|
||||
- Steppenwolf: Krise
|
||||
- Goldmund: Sinnlichkeit
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "In jedem Anfang liegt ein Zauber inne."
|
||||
|
||||
> "Man muss das Unmögliche versuchen, um das Mögliche zu erreichen."
|
||||
|
||||
> "Der Vogel kämpft sich aus dem Ei. Das Ei ist die Welt. Wer geboren werden will, muss eine Welt zerstören."
|
||||
|
||||
> "Glück ist Liebe, nichts anderes. Wer lieben kann, ist glücklich."
|
||||
|
||||
> "Die Welt zu durchschauen, sie zu verachten, mag großer Denker Sache sein. Mir aber liegt einzig daran, die Welt lieben zu können."
|
||||
|
||||
## Wirkung und Rezeption
|
||||
|
||||
### Zu Lebzeiten
|
||||
Zwiespältig: In Deutschland zeitweise verfemt, in der Schweiz verehrt. Weltweit gelesen, besonders nach Nobelpreis.
|
||||
|
||||
### 1960er Jahre
|
||||
Plötzliche Wiederentdeckung:
|
||||
- Hippie-Bewegung
|
||||
- Gegenkultur
|
||||
- Flower-Power
|
||||
- Millionenauflagen (bes. "Siddhartha", "Steppenwolf")
|
||||
|
||||
### Kritik
|
||||
- "Zu jugendlich"
|
||||
- "Narzistisch"
|
||||
- "Eskapistisch"
|
||||
- "Unpolitisch"
|
||||
|
||||
### Würdigung
|
||||
- Meister der Selbstfindung
|
||||
- Brücke zu östlicher Weisheit
|
||||
- Kritiker des Materialismus
|
||||
- Zeitlose Fragen
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Hermann Hesse bleibt einer der meistgelesenen deutschsprachigen Autoren:
|
||||
|
||||
- **Über 150 Millionen** verkaufte Bücher weltweit
|
||||
- **Generationen** von Jugendlichen inspiriert
|
||||
- **Ost-West-Synthese** als Vision
|
||||
- **Individuationsweg** als Lebensprogramm
|
||||
|
||||
Seine Romane sind Wegbegleiter für Suchende, Außenseiter und alle, die nach einem authentischen Leben streben. In einer zerrissenen Zeit bot er die Vision der Ganzheit, in einer materialistischen Welt den Weg nach innen.
|
||||
|
||||
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
|
||||
*26. April 121 - 17. März 180 n. Chr.*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Marcus Aurelius Antoninus Augustus war römischer Kaiser (161-180) und einer der bedeutendsten Vertreter der stoischen Philosophie. Seine "Selbstbetrachtungen" (Τὰ εἰς ἑαυτόν), persönliche Notizen zur stoischen Lebensführung, zählen zu den wichtigsten philosophischen Werken der Antike. Als letzter der "Fünf guten Kaiser" versuchte er, philosophische Ideale mit den Pflichten der Macht zu vereinen.
|
||||
|
||||
## Der Philosophenkaiser (121-161)
|
||||
|
||||
### Geburt und Familie
|
||||
Geboren als Marcus Annius Verus in Rom. Vater: Marcus Annius Verus (Prätor). Mutter: Domitia Lucilla. Großvater: Konsul und dreifacher Konsul, enger Freund Hadrians.
|
||||
|
||||
### Adoption durch Antoninus Pius (138)
|
||||
Kaiser Hadrian bestimmt Antoninus Pius als Nachfolger unter Bedingung: Adoption von Marcus und Lucius Verus. Marcus wird designierter Thronfolger mit 17 Jahren.
|
||||
|
||||
### Bildung
|
||||
Beste Lehrer Roms:
|
||||
- **Rhetoric**: Fronto (berühmtester Redner)
|
||||
- **Philosophie**: Junius Rusticus (Stoiker)
|
||||
- **Recht**: Verschiedene Juristen
|
||||
- **Griechisch**: Herodes Atticus
|
||||
|
||||
### Stoische Wende
|
||||
Mit 25 Jahren entscheidende Hinwendung zur Philosophie durch Junius Rusticus. Wollte als Asket leben, davon abgehalten. Trug stoisches Philosophengewand unter der Toga.
|
||||
|
||||
### Princeps Iuventutis
|
||||
Bereits als Thronerbe öffentliche Ämter:
|
||||
- Quästor
|
||||
- Konsul (mehrfach)
|
||||
- Tribunicia Potestas
|
||||
- Erlernen der Regierungsgeschäfte
|
||||
|
||||
### Ehe mit Faustina (145)
|
||||
Heirat mit Annia Galeria Faustina, Tochter des Antoninus Pius. 13 Kinder, nur 6 überleben. Gerüchte über Untreue (unbewiesen). Marcus bleibt ihr treu ergeben.
|
||||
|
||||
## Der regierende Kaiser (161-180)
|
||||
|
||||
### Gemeinsame Herrschaft
|
||||
Erste echte Doppelherrschaft (Diarchie): Marcus und Adoptivbruder Lucius Verus als gleichberechtigte Augusti. In Praxis: Marcus dominiert. Lucius stirbt 169.
|
||||
|
||||
### Äußere Bedrohungen
|
||||
Herrschaft geprägt von Krisen:
|
||||
- **Partherkrieg** (161-166): Lucius Verus führt Feldzug
|
||||
- **Markomannenkriege** (166-180): Germanen und Sarmaten bedrohen Donaugrenze
|
||||
- **Pest** (165-180): Antoninische Pest dezimiert Reich
|
||||
- **Usurpation**: Avidius Cassius in Syrien (175)
|
||||
|
||||
### Markomannenkriege
|
||||
Hauptkonflikt der Herrschaft:
|
||||
- Germanen überqueren Donau
|
||||
- Erstmals seit Jahrhunderten Italien bedroht
|
||||
- Marcus führt persönlich Krieg (ungewöhnlich für Kaiser)
|
||||
- Jahre im Feldlager an der Donau
|
||||
- Dort entstehen die "Selbstbetrachtungen"
|
||||
|
||||
### Innenpolitik
|
||||
- Juristische Reformen (humanere Rechtsprechung)
|
||||
- Schutz der Schwachen (Sklaven, Witwen, Waisen)
|
||||
- Finanzielle Belastung durch Kriege
|
||||
- Verkauf kaiserlicher Güter zur Finanzierung
|
||||
|
||||
### Christenverfolgung
|
||||
Paradox: Philosoph duldet Christenverfolgungen (Lyon 177). Sah Christen als staatsgefährdende Fanatiker. Stoische Pflichterfüllung über persönliche Toleranz.
|
||||
|
||||
## Selbstbetrachtungen
|
||||
|
||||
### Entstehung
|
||||
Geschrieben in griechisch im Feldlager (170-180). Privates Tagebuch, nie zur Veröffentlichung bestimmt. Titel "Ta eis heauton" (Zu sich selbst). 12 Bücher.
|
||||
|
||||
### Inhalt
|
||||
Keine systematische Philosophie, sondern:
|
||||
- Persönliche Ermahnungen
|
||||
- Danksagungen an Lehrer
|
||||
- Stoische Grundsätze
|
||||
- Kampf gegen Versuchungen
|
||||
- Pflichtethik
|
||||
- Todesbetrachtungen
|
||||
|
||||
### Buch I
|
||||
Dankbarkeit an Lehrer und Familie:
|
||||
- Großvater: Würde
|
||||
- Vater: Bescheidenheit
|
||||
- Mutter: Frömmigkeit
|
||||
- Lehrer: Stoische Weisheit
|
||||
- Antoninus Pius: Herrschertugenden
|
||||
|
||||
### Kernthemen
|
||||
**Logos**: Kosmische Vernunft
|
||||
**Apatheia**: Leidenschaftslosigkeit
|
||||
**Prohairesis**: Innere Freiheit
|
||||
**Pflicht**: Dienst am Ganzen
|
||||
**Tod**: Natürlicher Prozess
|
||||
**Gleichmut**: Gegenüber Schicksal
|
||||
|
||||
### Stil
|
||||
Schlicht und eindringlich:
|
||||
- Kurze Maximen
|
||||
- Wiederholungen (Selbstvergewisserung)
|
||||
- Ehrlichkeit über eigene Schwächen
|
||||
- Keine Pose
|
||||
- Ringen um Tugendhaftigkeit
|
||||
|
||||
## Stoische Philosophie
|
||||
|
||||
### Vier Kardinaltugenden
|
||||
- **Weisheit** (sophia): Einsicht in Natur
|
||||
- **Gerechtigkeit** (dikaiosyne): Pflicht zu anderen
|
||||
- **Tapferkeit** (andreia): Standhaftigkeit
|
||||
- **Mäßigung** (sophrosyne): Selbstbeherrschung
|
||||
|
||||
### Kosmopolitismus
|
||||
- Alle Menschen sind Bürger einer Welt-Polis
|
||||
- Pflicht zur Menschenliebe (philanthropia)
|
||||
- Auch Feinde sind Brüder
|
||||
- Kosmische Perspektive
|
||||
|
||||
### Memento Mori
|
||||
Ständige Vergegenwärtigung der Sterblichkeit:
|
||||
> "Denke daran, dass du bald niemand mehr sein wirst und nirgends mehr sein wirst."
|
||||
|
||||
Aber keine Verzweiflung: Tod als natürlich und notwendig.
|
||||
|
||||
### Prohairesis
|
||||
Innere Freiheit:
|
||||
- Äußeres unverfügbar
|
||||
- Innere Haltung allein in eigener Macht
|
||||
- Niemand kann Geist zwingen
|
||||
- Selbstbestimmung des Willens
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Das Glück deines Lebens hängt von der Beschaffenheit deiner Gedanken ab."
|
||||
|
||||
> "Wenn du am Morgen erwachst, denke daran, was für ein köstlicher Schatz es ist, zu leben, zu atmen, sich zu freuen."
|
||||
|
||||
> "Du hast Macht über deinen Geist - nicht über äußere Ereignisse. Erkenne das, und du wirst Stärke finden."
|
||||
|
||||
> "Alles, was geschieht, geschieht gerecht."
|
||||
|
||||
> "Die Seele wird eingefärbt von den Gedanken."
|
||||
|
||||
> "Wie der Gedanke der Seele, so wird die Seele."
|
||||
|
||||
## Tod und Nachfolge (180)
|
||||
|
||||
### Letzte Tage
|
||||
Im Feldlager bei Wien (Vindobona). Seuche bricht aus. Marcus erkrankt. Verweigert Nahrung (Selbsttötung durch Fasten?). Empfiehlt den Göttern das Reich.
|
||||
|
||||
### Tod am 17. März 180
|
||||
Stirbt im Feldlager mit 58 Jahren. Wird konsekriert (vergöttlicht). Letzte Worte unklar überliefert. Möglicherweise: "Geht zur aufgehenden Sonne, ich gehe unter."
|
||||
|
||||
### Commodus als Nachfolger
|
||||
Sohn Commodus (Mitkaiser seit 177) wird Alleinherrscher. Katastrophale Regierung. Bruch mit Vater-Idealen. Ende der "Adoptivkaiser-Zeit". Frage: Marcus' größter Fehler?
|
||||
|
||||
## Wirkungsgeschichte
|
||||
|
||||
### Antike
|
||||
"Selbstbetrachtungen" wenig bekannt. Ruf als weiser Kaiser. Säule in Rom (Triumphsäule, erhalten). Reiterstatue (heute Kapitol, Kopie).
|
||||
|
||||
### Mittelalter
|
||||
Weitgehend vergessen im Westen. Byzantinisches Reich bewahrt Text. Arabische Philosophen kennen Marc Aurel.
|
||||
|
||||
### Renaissance
|
||||
Wiederentdeckung. Erste Drucke 16. Jh. Begeisterung für antike Weisheit. Fürstenspiegel-Tradition.
|
||||
|
||||
### Neuzeit
|
||||
18./19. Jh.: Bewunderung als "Philosophenkaiser". Gibbon: Höhepunkt römischer Zivilisation. Romantics fasziniert von Melancholie.
|
||||
|
||||
### 20./21. Jahrhundert
|
||||
Ungebrochene Popularität:
|
||||
- Existentialisten (innere Freiheit)
|
||||
- Psychotherapie (Kogn
|
||||
|
||||
itive Therapie)
|
||||
- Selbsthilfe-Literatur
|
||||
- Stoizismus-Renaissance
|
||||
- Führungskräfte-Lektüre
|
||||
|
||||
## Historische Bewertung
|
||||
|
||||
### Würdigung
|
||||
- Letzter großer Kaiser der Pax Romana
|
||||
- Philosophenkaiser als Ideal
|
||||
- Menschliche Rechtsprechung
|
||||
- Pflichterfüllung unter widrigsten Umständen
|
||||
- Literarisches Meisterwerk
|
||||
|
||||
### Kritik
|
||||
- Christenverfolgung
|
||||
- Commodus als Nachfolger (Dynastieprinzip statt Adoption)
|
||||
- Militärisch defensiv
|
||||
- Reich am Ende geschwächt
|
||||
- Philosophie unpolitisch?
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Marcus Aurelius bleibt der Inbegriff des Philosophenherrschers - Platons Traum (fast) verwirklicht:
|
||||
|
||||
- **Selbstbetrachtungen** als zeitlose Weisheit
|
||||
- **Stoizismus** praktisch gelebt
|
||||
- **Pflichterfüllung** trotz persönlichem Leid
|
||||
- **Humanitas** als Herrscherideal
|
||||
|
||||
Seine Größe liegt nicht in militärischen Siegen oder territorialen Eroberungen, sondern im Versuch, ein guter Mensch zu sein unter der Last der Macht. Die "Selbstbetrachtungen" zeigen einen Kaiser, der mit sich ringt, der zweifelt, der sich selbst ermahnt - menschlicher als jedes Herrscherdenkmal.
|
||||
|
||||
In einer Zeit, da das römische Reich bereits Risse zeigte, hielt er durch Charakter und Pflichtgefühl zusammen, was historische Kräfte auseinanderdrängten. Sein Beispiel lehrt, dass Größe nicht in äußerem Erfolg, sondern in innerer Haltung liegt.`,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('📝 Batch 1: Featured Autoren\n');
|
||||
|
||||
const authorsDE = loadAuthors('de');
|
||||
const authorsEN = loadAuthors('en');
|
||||
|
||||
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 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`);
|
||||
|
||||
writeAuthors(updatedDE, 'de');
|
||||
writeAuthors(updatedEN, 'en');
|
||||
|
||||
console.log('\n✨ Batch 1 fertig!\n');
|
||||
}
|
||||
|
||||
main();
|
||||
742
apps/quote/apps/mobile/scripts/addBiosBatch2.ts
Normal file
742
apps/quote/apps/mobile/scripts/addBiosBatch2.ts
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Batch 2: Weitere Featured Autoren
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
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[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
|
||||
`;
|
||||
fs.writeFileSync(filePath, tsContent, 'utf-8');
|
||||
console.log(`✅ ${lang}.ts aktualisiert`);
|
||||
}
|
||||
|
||||
const batch2Bios: Record<string, string> = {
|
||||
'epiktet': `# Epiktet
|
||||
*ca. 50 - ca. 138 n. Chr.*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Epiktet war einer der bedeutendsten stoischen Philosophen der römischen Kaiserzeit. Als Sklave geboren, wurde er zum einflussreichsten Moralphilosophen seiner Epoche. Seine Lehrgespräche ("Diatribai") und das "Handbüchlein der Moral" (Encheiridion) prägten die stoische Ethik nachhaltig. Sein Kerngedanke: Wahre Freiheit liegt in der inneren Haltung, nicht in äußeren Umständen.
|
||||
|
||||
## Vom Sklaven zum Philosophen
|
||||
|
||||
### Sklaverei (ca. 50-80)
|
||||
Geboren in Hierapolis (Phrygien, heute Türkei) als Sklave. Name "Epiktet" bedeutet "der Erworbene" oder "Zugekaufte". Gehörte Epaphroditos, mächtigem Freigelassenen Kaiser Neros.
|
||||
|
||||
### Körperbehinderung
|
||||
Ein Bein blieb zeitlebens verkrüppelt. Erzählung (vielleicht Legende): Herr quälte ihn, Epiktet blieb gelassen und sagte voraus, dass Bein brechen würde - geschah, er blieb unerschüttert.
|
||||
|
||||
### Philosophische Bildung
|
||||
Trotz Sklaverei Zugang zu Bildung. Schüler des berühmten Stoikers Gaius Musonius Rufus in Rom. Intensive Auseinandersetzung mit stoischer Philosophie.
|
||||
|
||||
### Freilassung (ca. 80)
|
||||
Nach Neros Tod (68) und Tod des Epaphroditos (95) wurde Epiktet freigelassen. Begann selbst als Philosoph zu lehren in Rom.
|
||||
|
||||
## Die Philosophenschule in Nikopolis (ab 93/94)
|
||||
|
||||
### Verbannung (93/94)
|
||||
Kaiser Domitian verbannte Philosophen aus Rom (antintellektuelle Politik). Epiktet zog nach Nikopolis in Epirus (Griechenland).
|
||||
|
||||
### Schulgründung
|
||||
Gründete einflussreiche Philosophenschule. Einfaches Leben in Armut. Unterrichtete im Dialog. Berühmt für eindringliche Lehrgespräche.
|
||||
|
||||
### Berühmte Schüler
|
||||
- **Arrian**: Später Statthalter und Historiker, zeichnete Epiktets Lehren auf
|
||||
- **Gellius**: Römischer Schriftsteller
|
||||
- **Kaiser Hadrian**: Besuchte möglicherweise Schule
|
||||
- **Marcus Aurelius**: Las Epiktet intensiv
|
||||
|
||||
### Lebensweise
|
||||
Lebte asketisch: kleine Hütte, eisernes Lämpchen (Tonlampe gestohlen). Zölibat lange, spät adoption eines Waisenkindes. Bescheidenheit als Philosophenideal.
|
||||
|
||||
## Die Lehre
|
||||
|
||||
### Dihairesis - Grundunterscheidung
|
||||
Kern der Philosophie:
|
||||
**Eph' hemin / Ouk eph' hemin** (In unserer Macht / Nicht in unserer Macht)
|
||||
|
||||
**In unserer Macht:**
|
||||
- Meinungen (doxai)
|
||||
- Impulse (hormai)
|
||||
- Begierden (orexeis)
|
||||
- Abneigungen (ekkl
|
||||
|
||||
iseis)
|
||||
- Kurz: unsere Prohairesis (moralische Wahl)
|
||||
|
||||
**Nicht in unserer Macht:**
|
||||
- Körper
|
||||
- Besitz
|
||||
- Ruf
|
||||
- Ämter
|
||||
- Alles Äußere
|
||||
|
||||
### Prohairesis
|
||||
Zentral für Epiktet:
|
||||
- Moralisches Selbst
|
||||
- Fähigkeit zu wählen und zu urteilen
|
||||
- Unverwundbar durch Äußeres
|
||||
- Einziges wahrhaft Eigenes
|
||||
- Göttlicher Funke im Menschen
|
||||
|
||||
### Freiheit
|
||||
Wahre Freiheit = Unabhängigkeit von Äußerem:
|
||||
> "Nicht die Dinge beunruhigen die Menschen, sondern ihre Meinungen über die Dinge."
|
||||
|
||||
Sklave kann innerlich frei sein, Kaiser innerlich Sklave seiner Leidenschaften.
|
||||
|
||||
### Pflichten (Kathekonta)
|
||||
Soziale Rollen und Pflichten:
|
||||
- Als Sohn, Vater, Bürger
|
||||
- Vernunftgemäßes Handeln
|
||||
- Nicht weltfern, sondern praktisch
|
||||
- "Handle deiner Rolle gemäß"
|
||||
|
||||
## Encheiridion - Das Handbüchlein
|
||||
|
||||
### Entstehung
|
||||
Zusammengestellt von Schüler Arrian aus den Lehrgesprächen (Diatribai). 53 kurze Kapitel. Praktischer Leitfaden. Wurde "Handbüchlein der Moral" genannt.
|
||||
|
||||
### Erste Sätze (berühmt)
|
||||
> "Von den Dingen sind die einen in unserer Gewalt, die anderen nicht. In unserer Gewalt sind Meinung, Trieb, Begierde, Verabscheuung, kurz: alles, was unser eigenes Werk ist."
|
||||
|
||||
### Inhalte
|
||||
- Grundunterscheidung (s.o.)
|
||||
- Umgang mit Schicksalsschlägen
|
||||
- Soziale Rollen
|
||||
- Umgang mit Tod
|
||||
- Bescheidenheit
|
||||
- Selbstprüfung
|
||||
- Askese als Training
|
||||
|
||||
### Wirkung
|
||||
Wurde zum populärsten stoischen Text. Handlich und praktisch. Kompendium der Lebenskunst. Bis heute gelesen.
|
||||
|
||||
## Die Lehrgespräche (Diatribai)
|
||||
|
||||
### Form
|
||||
Lebendige Dialoge:
|
||||
- Arrian als stenographischer Aufzeichner
|
||||
- Sokrat
|
||||
|
||||
ischer Dialog
|
||||
- Rhetorische Fragen
|
||||
- Direkter Ton
|
||||
- Lebensnahe Beispiele
|
||||
|
||||
### Themen
|
||||
- Was ist Philosophie?
|
||||
- Über die Vorsehung
|
||||
- Über die Freiheit
|
||||
- Umgang mit Tyrannen
|
||||
- Über Krankheit und Tod
|
||||
- Über Familienpflichten
|
||||
- Kritik an Epikureern
|
||||
|
||||
### Stil
|
||||
- Derb und direkt
|
||||
- Bildhafte Sprache
|
||||
- Alltagsbeispiele
|
||||
- Humorvoll
|
||||
- Manchmal rau
|
||||
|
||||
### Erhaltung
|
||||
Von ursprünglich 8 Büchern nur 4 erhalten (durch Arrian). Authentischer als bei anderen antiken Philosophen.
|
||||
|
||||
## Zentrale Gedanken
|
||||
|
||||
### Gelassenheit (Ataraxia)
|
||||
Seelenruhe durch:
|
||||
- Unterscheidung Verfügbar/Unverfügbar
|
||||
- Nur Inneres kontrollierbar
|
||||
- Äußeres mit Gleichmut annehmen
|
||||
- "Amor fati" avant la lettre
|
||||
|
||||
### Prüfungen (Gymnasmata)
|
||||
Leben als Training:
|
||||
- Äußeres als Test
|
||||
- Charakterbildung
|
||||
- Selbstdisziplin
|
||||
- "Was würde Sokrates tun?"
|
||||
|
||||
### Todesverachtung
|
||||
Tod ist nichts Schlimmes:
|
||||
- Natürlicher Prozess
|
||||
- Rückgabe des Geliehenen
|
||||
- "Wann die Trompete ruft, gehorche"
|
||||
- Sokrates als Vorbild
|
||||
|
||||
### Gottvertrauen
|
||||
Stoischer Monotheismus:
|
||||
- Zeus als Vorsehung
|
||||
- Kosmos vernünftig geordnet
|
||||
- Mensch als Fragment Gottes
|
||||
- Vertrauen in göttliche Ordnung
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Nicht die Dinge selbst beunruhigen die Menschen, sondern ihre Meinungen über die Dinge."
|
||||
|
||||
> "Verlange nicht, dass die Dinge geschehen, wie du es wünschst, sondern wünsche, dass sie geschehen, wie sie geschehen, und es wird dir gut gehen."
|
||||
|
||||
> "Wenn du küsst dein Kindlein oder dein Weib, so sprich, dass du einen Menschen küssest; denn wenn es stirbt, wirst du nicht betrübt sein."
|
||||
|
||||
> "Es sind nicht die Dinge, die uns beunruhigen, sondern die Meinungen, die wir von den Dingen haben."
|
||||
|
||||
> "Du bist eine kleine Seele, die einen Leichnam mit sich herumträgt."
|
||||
|
||||
## Einfluss und Wirkung
|
||||
|
||||
### Antike
|
||||
Marcus Aurelius las Epiktet intensiv ("Selbstbetrachtungen" voller Anklänge). Favorinus kritisierte. Lukian parodierte. Bis Spätantike gelesen.
|
||||
|
||||
### Christentum
|
||||
Frühe Christen fasziniert:
|
||||
- Simplicius (Neuplatoniker, 6. Jh.): Kommentar
|
||||
- Mönchstum: Askese-Ideal
|
||||
- Mittelalter: Weitergel
|
||||
|
||||
esen
|
||||
- "Fast ein Christ" (Kirchenväter)
|
||||
|
||||
### Neuzeit
|
||||
Renaissance-Humanismus: Wiederentdeckung. 1670: Erstmals komplett gedruckt. Einfluss auf:
|
||||
- **Montaigne**: Essais
|
||||
- **Pascal**: Gedanken
|
||||
- **Descartes**: Stoische Ethik
|
||||
- **Spinoza**: Gelassenheit
|
||||
|
||||
### Moderne
|
||||
Ungebrochene Aktualität:
|
||||
- **Existenzphilosophie**: Heidegger, Sartre (Freiheitsbegriff)
|
||||
- **Kognitive Therapie**: Ellis, Beck (Gedanken ändern Gefühle)
|
||||
- **Resilienz-Forschung**: Umgang mit Unverfügbarem
|
||||
- **Stoizismus-Renaissance**: 21. Jh. (Ryan Holiday u.a.)
|
||||
|
||||
## Epiktet und die Stoa
|
||||
|
||||
### Orthodoxer Stoiker
|
||||
Treu zur Schultradition:
|
||||
- Chrysipp und Zenon zitiert
|
||||
- Kleanthes' Hymnus an Zeus
|
||||
- Keine Innovation in Physik/Logik
|
||||
- Fokus auf Ethik
|
||||
|
||||
### Vereinfachung
|
||||
Konzentration auf Praktisches:
|
||||
- Weniger Systematik als Chrysipp
|
||||
- Dihairesis als Kern
|
||||
- Alltagstauglich
|
||||
- Für Nichtphilosophen
|
||||
|
||||
### Sokratisches Element
|
||||
Sokrates als Ideal:
|
||||
- Dialog-Form
|
||||
- Selbstprüfung
|
||||
- Leben nach Prinzipien
|
||||
- Tod als Befreiung
|
||||
|
||||
## Historische Einordnung
|
||||
|
||||
### Kaiserzeit-Stoa
|
||||
Repräsentant der römischen Stoa:
|
||||
- Nach Seneca, vor Marc Aurel
|
||||
- Praktische Ethik im Vordergrund
|
||||
- Politische Zurückhaltung
|
||||
- Individuelle Seelenführung
|
||||
|
||||
### Sklavenperspektive
|
||||
Einzigartig:
|
||||
- Philosophie aus Sklavenerfahrung
|
||||
- Freiheit von innen trotz äußerer Unfreiheit
|
||||
- Glaubwürdigkeit durch Lebenspraxis
|
||||
- Radikalität der Haltung
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Epiktet bleibt der praktischste Philosoph der Antike:
|
||||
|
||||
- **Handbuch fürs Leben** - bis heute benutzbar
|
||||
- **Freiheit durch Haltung** - zeitlose Weisheit
|
||||
- **Sklave als Weiser** - Umkehrung aller Werte
|
||||
- **Unverwundbarkeit** - durch Verzicht auf Unverfügbares
|
||||
|
||||
Seine Größe liegt in der Radikalität, mit der er seine Philosophie lebte. Als Sklave lehrte er Freiheit, als Armer Glück, als Körperbehinderter Seelenruhe. Seine Botschaft: Äußere Umstände haben keine Macht über uns, wenn wir es nicht zulassen.
|
||||
|
||||
Das Encheiridion ist vielleicht der beste Beweis, dass Philosophie keine akademische Disziplin, sondern Lebenskunst ist. In 53 kurzen Kapiteln zeigt Epiktet, wie man ein gutes Leben führt - nicht in perfekten Umständen, sondern trotz aller Widrigkeiten.
|
||||
|
||||
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
|
||||
*24. August 1947 - heute*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Paulo Coelho ist ein brasilianischer Schriftsteller und einer der meistgelesenen Autoren der Gegenwart. Sein Welterfolg "Der Alchimist" (1988) wurde in über 80 Sprachen übersetzt und mehr als 150 Millionen Mal verkauft. Seine spirituellen Romane verbinden mystische Weisheit mit modernem Sinnsuchen und inspirieren Millionen von Lesern weltweit. Von der Militärdiktatur verfolgt und dreimal in psychiatrische Kliniken eingewiesen, wurde er zum Bestsellerautor und Mitglied der Brasilianischen Akademie der Literatur.
|
||||
|
||||
## Frühe Jahre in Rio de Janeiro (1947-1970)
|
||||
|
||||
### Mittelschichtfamilie
|
||||
Geboren in Rio de Janeiro als Sohn eines Ingenieurs. Jesuitenschule. Wollte Schriftsteller werden - Eltern wünschten Ingenieur. Frühe Rebellion gegen Konventionen.
|
||||
|
||||
### Psychiatrische Einweisungen
|
||||
Mit 17, 20 und 21 Jahren von Eltern in psychiatrische Kliniken eingewiesen. Angeblich "abnormales Verhalten". Elektroschocks. Traumatische Erfahrung. Versuch, Träume zu töten.
|
||||
|
||||
### Hippie-Jahre
|
||||
1960er: Teil der Gegenkultur. Langes Haar, Drogen, alternativer Lebensstil. Gegen Militärdiktatur. Interesse an Mystik und Okkultismus.
|
||||
|
||||
## Der Weg zur Magie (1970-1987)
|
||||
|
||||
### Journalist und Songwriter
|
||||
Arbeit als Journalist. Erfolgreicher Texter für brasilianische Rockmusik. Raul Seixas als Partner. Hits in Brasilien. Finanzieller Erfolg, aber innerliche Leere.
|
||||
|
||||
### Verhaftung (1974)
|
||||
Militärdiktatur verhaftet ihn wegen subversiver Texte. Folter angedroht. Nach Tagen freigelassen. Traumatische Erfahrung verstärkt Sinnsuche.
|
||||
|
||||
### Satanismus-Phase
|
||||
Kurze Beschäftigung mit schwarzer Magie und Satanismus. Führte zu Krise. Erkannte Gefahr. Wendung zur Spiritualität.
|
||||
|
||||
### Pilgerreise nach Santiago (1986)
|
||||
Entscheidende Erfahrung: Jakobsweg mit Frau Christina. 700 km zu Fuß. Spirituelle Transformation. Grundlage für ersten Roman "Auf dem Jakobsweg" (1987).
|
||||
|
||||
### RAM-Orden
|
||||
Mitglied des katholisch-mystischen Ordens RAM (Regnus Agnus Mundi). Verbindung von Christentum und Esoterik. Rituale und Initiationen.
|
||||
|
||||
## Der Durchbruch (1988-1993)
|
||||
|
||||
### Der Alchimist (1988)
|
||||
Parabel über einen andalusischen Hirten auf der Suche nach Schatz. Erste Auflage: 900 Exemplare, Flop. Verlag gibt auf. Neuer Verlag 1990. Plötzlich Mundpropaganda. Bestseller in Brasilien.
|
||||
|
||||
### Weltweiter Erfolg
|
||||
Übersetzungen in alle Sprachen. 1993 Durchbruch in USA. Oprah Winfrey-Empfehlung. Phänomen: Je mehr Zeit vergeht, desto mehr Verkäufe. Kultbuch der Selbstfindung.
|
||||
|
||||
### Themen des Alchimisten
|
||||
- Persönliche Legende (Lebensaufgabe)
|
||||
- Weltenseele
|
||||
- Zeichen lesen lernen
|
||||
- Träume verwirklichen
|
||||
- Schatz im eigenen Herzen
|
||||
|
||||
### Kritik und Verteidigung
|
||||
Kritiker: Kitschig, esoterisch, seicht. Fans: Inspirierend, lebensverändernd. Coelho: "Ich schreibe fürs Herz, nicht für Kritiker."
|
||||
|
||||
## Schriftstellerleben (1993-heute)
|
||||
|
||||
### Produktivität
|
||||
Etwa alle zwei Jahre ein neuer Roman. Über 30 Bücher. Routinierter Arbeitsprozess. Vorbereitung durch Recherche und Reisen.
|
||||
|
||||
### Themen
|
||||
Wiederkehrende Motive:
|
||||
- Spirituelle Suche
|
||||
- Persönliche Transformation
|
||||
- Mut zum Träumen
|
||||
- Liebe als Kraft
|
||||
- Zeichen und Omen
|
||||
- Schicksal und freier Wille
|
||||
|
||||
### Wichtige Werke
|
||||
- **Am Ufer des Rio Piedra saß ich und weinte** (1994): Liebe und spirituelles Erwachen
|
||||
- **Veronika beschließt zu sterben** (1998): Psychiatrie und Lebenswille
|
||||
- **Der Dämon und Fräulein Prym** (2000): Gut und Böse
|
||||
- **Elf Minuten** (2003): Sexualität und heilige Prostitution
|
||||
- **Der Zahir** (2005): Obsession und Freiheit
|
||||
- **Die Hexe von Portobello** (2006): Weibliche Spiritualität
|
||||
- **Aleph** (2011): Zeitreise und Reinkarnation
|
||||
|
||||
### Kontroversen
|
||||
- Plagiatsvorwürfe (nie bewiesen)
|
||||
- Esoterik-Kritik
|
||||
- Vereinfachung spiritueller Traditionen
|
||||
- Kommerzialismus
|
||||
|
||||
## Erfolg und Zahlen
|
||||
|
||||
### Verkaufszahlen
|
||||
- Über 320 Millionen verkaufte Bücher
|
||||
- Übersetzungen in 83 Sprachen
|
||||
- Meist-übersetzer Autor der Welt (Guinness)
|
||||
- "Der Alchimist" allein über 150 Millionen
|
||||
|
||||
### Auszeichnungen
|
||||
- Mitglied der Brasilianischen Akademie der Literatur (2002)
|
||||
- Chevalier de l'Ordre national de la Légion d'honneur (Frankreich)
|
||||
- Crystal Award (Weltwirtschaftsforum)
|
||||
- Zahlreiche internationale Ehrungen
|
||||
|
||||
### Einfluss
|
||||
Leser aus allen Kulturen. Besonders erfolgreich in:
|
||||
- Iran (trotz Zensur)
|
||||
- Türkei
|
||||
- Japan
|
||||
- Russland
|
||||
- Europa
|
||||
- Lateinamerika
|
||||
|
||||
## Philosophie und Spiritualität
|
||||
|
||||
### Persönliche Legende
|
||||
Kernkonzept:
|
||||
- Jeder hat einzigartige Lebensaufgabe
|
||||
- Universum hilft bei Verwirklichung
|
||||
- Zeichen weisen den Weg
|
||||
- Mut erfordert, zu folgen
|
||||
|
||||
### Synchronizität
|
||||
Inspiration durch C.G. Jung:
|
||||
- Bedeutungsvolle Zufälle
|
||||
- Alles ist verbunden
|
||||
- Achte auf Omen
|
||||
- Universum spricht zu uns
|
||||
|
||||
### Weltenseele
|
||||
Stoische/neuplatonische Idee:
|
||||
- Alles ist eins
|
||||
- Liebe als verbindende Kraft
|
||||
- Kommunikation mit Kosmos möglich
|
||||
- Pantheistische Elemente
|
||||
|
||||
### Katholizismus + Esoterik
|
||||
Eigenwillige Mischung:
|
||||
- Katholisch getauft und sozialisiert
|
||||
- Jakobsweg als katholische Tradition
|
||||
- Aber: Reinkarnation, Magie, I Ging
|
||||
- Synkretismus
|
||||
|
||||
## Digitale Präsenz
|
||||
|
||||
### Social Media Pioneer
|
||||
Früher Blogger (seit 1996). Aktiv auf:
|
||||
- Facebook: Millionen Follower
|
||||
- Twitter/X: Inspirierende Tweets
|
||||
- Instagram: Visuell-spirituell
|
||||
Teilt Gedanken, Reisen, Weisheiten täglich.
|
||||
|
||||
### Piraterie-Haltung
|
||||
Ungewöhnlich: Toleriert illegale Downloads. "Wichtig ist, gelesen zu werden, nicht Geld." Sieht Piraterie als Marketing. Dennoch Bestseller.
|
||||
|
||||
## Paulo Coelho als Person
|
||||
|
||||
### Ehe
|
||||
Seit 1980 mit der Künstlerin Christina Oiticica verheiratet. Keine Kinder. Jakobsweg gemeinsam. Sie inspiriert viele Charaktere.
|
||||
|
||||
### Wohnsitze
|
||||
Hauptwohnsitz: Genf (Schweiz). Auch in Rio. Vielreisend. Kosmopolit.
|
||||
|
||||
### Routine
|
||||
Disziplinierter Arbeiter. Morgenschreiber. Recherche-intensiv. Jeden Tag kleine Schritte. "Zehn Prozent Inspiration, neunzig Prozent Transpiration."
|
||||
|
||||
### Persönlichkeit
|
||||
Öffentlich: Charismatisch, spirituell, weise. Privat: Diszipliniert, fleißig, strategisch. Geschäftsmann und Mystiker zugleich.
|
||||
|
||||
## Kritik
|
||||
|
||||
### Literarisch
|
||||
- "Esoterischer Kitsch"
|
||||
- "Oberflächlich"
|
||||
- "Vereinfachend"
|
||||
- "Kommerziell"
|
||||
- "Selbsthilfe, keine Literatur"
|
||||
|
||||
### Philosophisch
|
||||
- "Synkretistische Beliebigkeit"
|
||||
- "Konsumismus der Spiritualität"
|
||||
- "Falsche Versprechungen"
|
||||
- "Individualismus statt Soziales"
|
||||
|
||||
### Verteidigung
|
||||
Coelho: "Ich schreibe keine Literatur für Professoren. Ich schreibe, um Menschen zu helfen, sich selbst zu finden."
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Wenn du etwas wirklich willst, dann wird das gesamte Universum darauf hinwirken, dass du es erreichst."
|
||||
|
||||
> "Die Welt liegt in den Händen derer, die den Mut haben, zu träumen und das Risiko einzugehen, ihre Träume zu leben."
|
||||
|
||||
> "Du bist nicht krank, du bist traurig. Es ist
|
||||
|
||||
die moderne Welt."
|
||||
|
||||
> "Es gibt nur eine Sache, die einen Traum unmöglich macht: die Angst vor dem Scheitern."
|
||||
|
||||
> "Jeder Tag ist ein neuer Anfang."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Paulo Coelho bleibt einer der einflussreichsten Schriftsteller des 21. Jahrhunderts:
|
||||
|
||||
- **Inspirator von Millionen** - Leser finden Mut zum Träumen
|
||||
- **Brückenbauer** zwischen Kulturen und Religionen
|
||||
- **Pionier** der spirituellen Literatur
|
||||
- **Phänomen** der Mundpropaganda-Vermarktung
|
||||
|
||||
Seine Größe liegt nicht in literarischer Kunstfertigkeit (die Kritiker vermissen), sondern in der Fähigkeit, Menschen zu berühren. Er gibt einfache Antworten auf komplexe Fragen - und genau das suchen Millionen.
|
||||
|
||||
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
|
||||
*6. Januar 1883 - 10. April 1931*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Khalil Gibran (arabisch جبران خليل جبران) war ein libanesisch-amerikanischer Dichter, Philosoph und Maler. Sein Hauptwerk "Der Prophet" (1923) gehört zu den meistverkauften Büchern aller Zeiten und wurde in über 100 Sprachen übersetzt. Als Brücke zwischen östlicher Mystik und westlicher Moderne prägte er eine Generation von Suchenden. Seine poetisch-philosophischen Texte über Liebe, Freiheit und menschliche Bestimmung berühren bis heute Menschen weltweit.
|
||||
|
||||
## Kindheit im Libanon (1883-1895)
|
||||
|
||||
### Bsharri
|
||||
Geboren in Bsharri, christlich-maronitisches Bergdorf im Nordlibanon. Familie gehörte zur christlichen Minderheit. Vater Khalil Sa'd Gibran: Steuereintreiber, Trinker, Glücksspieler. Mutter Kamila Rahmeh: stark, fromm, Halt der Familie.
|
||||
|
||||
### Schwierige Familienverhältnisse
|
||||
Vater verschuldet, inhaftiert wegen Korruption. Familie verarmt. Mutter entscheidet zur Auswanderung. Libanesische Diaspora nach Amerika.
|
||||
|
||||
### Prägende Landschaft
|
||||
Heilige Zedern des Libanon. Wadi Qadisha (Heiliges Tal). Klöster und Einsiedeleien. Landschaft durchzog alle späteren Werke.
|
||||
|
||||
## Boston und New York (1895-1931)
|
||||
|
||||
### Einwanderung nach Boston (1895)
|
||||
Mit Mutter und Geschwistern (Vater blieb). Armes Einwandererviertel. Khalil lernte Englisch in öffentlicher Schule. Talent für Zeichnen entdeckt.
|
||||
|
||||
### Fred Holland Day
|
||||
Fotograf und Kulturmäzen entdeckt Khalil. Führt ihn in Bostoner Bohème ein. Gibran porträtiert prominente Persönlichkeiten. Erste künstlerische Förderung.
|
||||
|
||||
### Rückkehr in den Libanon (1898-1902)
|
||||
Zur weiteren Ausbildung nach Beirut, Collège de la Sagesse. Studium Arabisch, Französisch, Literatur. Begeisterung für arabische Romantik. Erste arabische Gedichte.
|
||||
|
||||
### Familiäre Tragödien (1902-1904)
|
||||
Rückkehr nach Boston. Schwester Sultana stirbt an Tuberkulose (14 Jahre). Bruder Boutros stirbt (Tuberkulose). Mutter stirbt (Krebs). Innerhalb von zwei Jahren drei Verluste. Nur Schwester Marianna überlebt, wird lebenslange Stütze.
|
||||
|
||||
### Mary Elizabeth Haskell
|
||||
1904 Begegnung mit Schulleiterin Mary Haskell (10 Jahre älter). Mäzenin, Mentorin, Vertraute. Finanziert Studium in Paris. Briefwechsel über 25 Jahre. Komplizierte Liebe (Ehe abgelehnt, aber lebenslang verbunden).
|
||||
|
||||
### Paris (1908-1910)
|
||||
Studium an der Académie Julian. Treffen mit Rodin (großer Einfluss). Verkehr in Künstlerkreisen. Malstil entwickelt sich. Beeinflusst von Symbolismus und Art Nouveau.
|
||||
|
||||
### New York (ab 1911)
|
||||
Umzug nach New York, Atelier in Greenwich Village. Schreibt zunehmend auf Englisch. Kontakte zu Literatenszene. Marianna führt Haushalt und Galerie.
|
||||
|
||||
## Der arabische Erneuerer (1905-1918)
|
||||
|
||||
### "Tränen und Lachen" (1914)
|
||||
Erste Sammlung arabischer Essays. Kritik an religiösem Dogmatismus. Angriff auf Unterdrückung. Ruf nach Freiheit und Liebe.
|
||||
|
||||
### "Die gebrochenen Flügel" (1912)
|
||||
Arabischer Roman. Tragische Liebesgeschichte. Kritik an arrangierten Ehen und Patriarchat. Bestseller in arabischer Welt.
|
||||
|
||||
### Al-Mahjar - Die Emigrantendichter
|
||||
Mitbegründer der "Pen League" arabischer Emigranten-Schriftsteller in New York. Mit Ameen Rihani, Mikhail Naimy. Erneuerung arabischer Literatur. Befreiung von klassischen Formen.
|
||||
|
||||
### Sprache und Stil
|
||||
Biblischer Ton in Arabisch. Kurze, poetische Sätze. Parabeln und Gleichnisse. Natursymbolik. Mystische Bilder.
|
||||
|
||||
## Der Prophet (1923)
|
||||
|
||||
### Entstehung
|
||||
Jahrelange Arbeit. Immer wieder überarbeitet. Mary Haskell korrigierte Englisch. Vereint östliche Weisheit und westliche Moderne.
|
||||
|
||||
### Struktur
|
||||
Almustafa (der Prophet) verlässt Stadt Orphalese nach 12 Jahren. Bewohner bitten um Weisheit. 26 Themen in poetischer Prosa.
|
||||
|
||||
### Themen
|
||||
- Liebe
|
||||
- Ehe
|
||||
- Kinder
|
||||
- Arbeit
|
||||
- Freude und Schmerz
|
||||
- Häuser
|
||||
- Kleider
|
||||
- Kaufen und Verkaufen
|
||||
- Verbrechen und Strafe
|
||||
- Gesetze
|
||||
- Freiheit
|
||||
- Vernunft und Leidenschaft
|
||||
- Schmerz
|
||||
- Selbsterkenntnis
|
||||
- Lehren
|
||||
- Freundschaft
|
||||
- Sprechen
|
||||
- Zeit
|
||||
- Gut und Böse
|
||||
- Gebet
|
||||
- Wonne
|
||||
- Schönheit
|
||||
- Religion
|
||||
- Tod
|
||||
- Abschied
|
||||
|
||||
### Über die Liebe (berühmteste Passage)
|
||||
> "Wenn die Liebe dir winkt, folge ihr, sind ihre Wege auch schwer und steil."
|
||||
> "Die Liebe gibt nichts als sich selbst und nimmt nichts als von sich selbst."
|
||||
|
||||
### Über Kinder
|
||||
> "Eure Kinder sind nicht eure Kinder. Sie sind die Söhne und Töchter der Sehnsucht des Lebens nach sich selbst."
|
||||
> "Ihr könnt ihnen eure Liebe geben, aber nicht eure Gedanken, denn sie haben ihre eigenen Gedanken."
|
||||
|
||||
### Erfolg
|
||||
Anfangs bescheiden. Dann Mundpropaganda. Steady Seller über Jahrzehnte. Über 100 Millionen verkaufte Exemplare. Dritt-meist-verkauftes poetisches Werk nach Bibel und Laozi.
|
||||
|
||||
## Philosophie und Spiritualität
|
||||
|
||||
### Mystische Einheit
|
||||
Alle Religionen als Wege zum Einen. Sufismus, Christentum, östliche Weisheit verschmolzen. Pantheistische Grundhaltung. Gott in allem.
|
||||
|
||||
### Freiheit und Liebe
|
||||
Zentrale Werte:
|
||||
- Freiheit als höchstes Gut
|
||||
- Liebe als göttliche Kraft
|
||||
- Individuum vor Institution
|
||||
- Intuition vor Dogma
|
||||
|
||||
### Schmerz und Freude
|
||||
Paradoxes Denken:
|
||||
- Schmerz und Freude untrennbar
|
||||
- Leid als Lehrmeister
|
||||
- Tod als Übergang
|
||||
- Gegensätze vereint
|
||||
|
||||
### Soziale Kritik
|
||||
Gegen:
|
||||
- Materialismus
|
||||
- Unterdrückung
|
||||
- Religiösen Zwang
|
||||
- Patriarchat
|
||||
- Imperialismus
|
||||
|
||||
## Als Maler
|
||||
|
||||
### Stil
|
||||
Symbolistisch. Fließende, erotische Formen. Engel, Propheten, weibliche Akte. Blake-Einfluss. Mystische Vision.
|
||||
|
||||
### Ausstellungen
|
||||
Regelmäßige Ausstellungen in New York und Boston. Porträts von Prominenten (Yeats, Jung, Tagore). Verkäufe bescheiden, aber respektiert.
|
||||
|
||||
### Buchillustrationen
|
||||
Illustrierte eigene Werke. Verschmelzung von Text und Bild. Gesamtkunstwerk-Anspruch.
|
||||
|
||||
## Späte Werke
|
||||
|
||||
### "Der Garten des Propheten" (1933, posthum)
|
||||
Fortsetzung des Propheten. Unvollendet. Von Mary Haskell zusammengestellt.
|
||||
|
||||
### "Jesus, der Menschensohn" (1928)
|
||||
Jesus aus Perspektive von 77 Zeitgenossen. Humanistisches Jesus-Bild. Christus als Poet und Rebell.
|
||||
|
||||
### "Sand und Schaum" (1926)
|
||||
Aphorismen-Sammlung. Poetische Kurztexte. Mystische Weisheiten.
|
||||
|
||||
## Tod und Vermächtnis (1931)
|
||||
|
||||
### Krankheit
|
||||
Leberzirrhose und Tuberkulose. Lebenslang Lungenprobleme. Alkoholkonsum verschlimmerte. Letztes Jahr im Krankenhaus.
|
||||
|
||||
### Tod
|
||||
10. April 1931 in New York, 48 Jahre alt. Letzte Worte (angeblich): "This is my last breath, and I love you all."
|
||||
|
||||
### Rückführung
|
||||
Leichnam nach Libanon überführt. Beigesetzt in Mar Sarkis-Kloster, Bsharri. Heute Museum (Gibran-Museum). Nationalheld des Libanon.
|
||||
|
||||
### Testament
|
||||
Vermögen und Werke an Bsharri. Schwester Marianna verwaltete Nachlass bis 1972.
|
||||
|
||||
## Wirkung und Rezeption
|
||||
|
||||
### Kultbuch der 1960er/70er
|
||||
"Der Prophet" Lieblingsbuch der Hippies. Hochzeiten und Beerdigungen. Gegenkultur-Bibel. John F. Kennedy zitierte Gibran. Elvis besaß "Der Prophet".
|
||||
|
||||
### Kritik
|
||||
Literaturkritiker: Kitschig, seicht, pseudo-mystisch. "Hallmark-Philosophie". Zu eklektisch. Zu glatt.
|
||||
|
||||
### Verteidigung
|
||||
Fans: Authentisch berührend. Zeitlose Weisheit. Trost spendend. Form egal, wenn Herz erreicht.
|
||||
|
||||
### Bis heute
|
||||
Millionen Exemplare jährlich verkauft. Übersetzungen in 110+ Sprachen. Besonders populär: USA, Arabische Welt, Indien, Lateinamerika.
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Du sollst frei sein von allem, ausgenommen von deiner Freiheit."
|
||||
|
||||
> "Und vergesst nicht, dass die Erde sich freut, eure nackten Füße zu fühlen, und die Winde sehnen sich danach, mit eurem Haar zu spielen."
|
||||
|
||||
> "Denn was ist Böses, wenn nicht Gutes, das von seinem eigenen Hunger und Durst gequält wird?"
|
||||
|
||||
> "Ich lerne Schweigen von den Geschwätzigen, Toleranz von den Intoleranten und Freundlichkeit von den Unfreundlichen. Seltsam, dass ich diesen Lehrern nicht dankbar bin."
|
||||
|
||||
> "Wenn du Liebe gibst, gibst du alles."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Khalil Gibran bleibt ein Prophet des 20. Jahrhunderts:
|
||||
|
||||
- **Brücke** zwischen Ost und West
|
||||
- **Stimme** der arabischen Renaissance
|
||||
- **Dichter** der universalen Spiritualität
|
||||
- **Künstler** der mystischen Vision
|
||||
|
||||
Seine Größe liegt in der Fähigkeit, tiefe Wahrheiten in einfacher Schönheit auszudrücken. Ob man seine Philosophie als tief oder seicht beurteilt - sein Einfluss auf Millionen ist unbestreitbar.
|
||||
|
||||
In einer fragmentierten Welt bot er die Vision der Einheit. In einer Zeit des Materialismus erinnerte er an das Spirituelle. In einer Ära des Dogmas predigte er die Freiheit. "Der Prophet" bleibt ein Geschenk an die Menschheit - imperfekt vielleicht, aber authentisch aus dem Herzen eines Suchenden.`,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('📝 Batch 2: Featured Autoren\n');
|
||||
|
||||
const authorsDE = loadAuthors('de');
|
||||
const authorsEN = loadAuthors('en');
|
||||
|
||||
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 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`);
|
||||
|
||||
writeAuthors(updatedDE, 'de');
|
||||
writeAuthors(updatedEN, 'en');
|
||||
|
||||
console.log('\n✨ Batch 2 fertig!\n');
|
||||
}
|
||||
|
||||
main();
|
||||
968
apps/quote/apps/mobile/scripts/addBiosBatch3.ts
Normal file
968
apps/quote/apps/mobile/scripts/addBiosBatch3.ts
Normal file
|
|
@ -0,0 +1,968 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Batch 3: Bruce Lee, Charlie Chaplin, David Ben-Gurion, Jean-Jacques Rousseau
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
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[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
|
||||
`;
|
||||
fs.writeFileSync(filePath, tsContent, 'utf-8');
|
||||
console.log(`✅ ${lang}.ts aktualisiert`);
|
||||
}
|
||||
|
||||
const batch3Bios: Record<string, string> = {
|
||||
'lee-bruce': `# Bruce Lee
|
||||
*27. November 1940 - 20. Juli 1973*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Bruce Lee war ein chinesisch-amerikanischer Kampfkünstler, Schauspieler, Filmregisseur und Philosoph, der die Kampfkunst revolutionierte und zum globalen Kultstar wurde. Als Begründer des Jeet Kune Do verband er östliche Kampfkunst mit westlicher Effizienz und eigener Philosophie. Seine Filme machten asiatische Kampfkunst weltweit populär und durchbrachen rassistische Barrieren Hollywoods. Trotz seines frühen Todes mit 32 Jahren bleibt sein Einfluss auf Kampfkunst, Film und Popkultur ungebrochen.
|
||||
|
||||
## Kindheit zwischen Ost und West (1940-1959)
|
||||
|
||||
### Geburt in San Francisco
|
||||
Geboren als Lee Jun-fan (李振藩) in San Francisco während Tournee seines Vaters Lee Hoi-chuen, kantonesischer Opern-Star. Amerikanische Staatsbürgerschaft. Chinesischer Name bedeutet "Rückkehr wieder" - Hoffnung auf Rückkehr nach Amerika.
|
||||
|
||||
### Hongkong-Jahre
|
||||
Familie kehrt nach Hongkong zurück (1941). Aufwachsen in Kowloon. Wohlhabende Familie, aber turbulente Zeiten. Japanische Besatzung (1941-1945) überstanden. Straßenkämpfe in der Jugend.
|
||||
|
||||
### Kinderstar
|
||||
Ab 5 Jahren in kantonesischen Filmen. Über 20 Kinderfilme bis 18. Rolle in "The Kid" (1950) machte bekannt. Bezauberndes Kind, später rebellischer Teenager.
|
||||
|
||||
### Wing Chun bei Yip Man
|
||||
Mit 13 Jahren beginnt Training bei Meister Yip Man (Ip Man). Wing Chun-Kung Fu. Zunächst als Straßenkampf-Training. Entwickelt Leidenschaft. Trainiert obsessiv. Yip Man erkennt Talent.
|
||||
|
||||
### Straßenkämpfe
|
||||
Teenager-Banden in Hongkong. Bruce oft in Schlägereien. Eltern besorgt. Polizeiliche Probleme drohen. Entscheidung: Bruce muss weg nach Amerika.
|
||||
|
||||
## Amerika: Von Seattle nach Hollywood (1959-1971)
|
||||
|
||||
### Neuanfang in Seattle (1959)
|
||||
Mit 18 nach San Francisco, dann Seattle. Bei Freunden der Familie. Arbeitet als Kellner. Beendet Highschool. Studiert Philosophie an University of Washington.
|
||||
|
||||
### Kampfkunstschule in Seattle
|
||||
Beginnt Wing Chun zu unterrichten. Jun Fan Gung Fu Institute eröffnet. Unorthodox: Unterrichtet auch Nicht-Chinesen (tabu). Entwickelt eigene Methoden.
|
||||
|
||||
### Linda Emery
|
||||
Trifft Linda, weiße Studentin, in seiner Kampfkunstklasse. Liebe gegen Konventionen. Heirat 1964. Tochter Shannon (1969), Sohn Brandon (1965). Linda wird lebenslange Partnerin.
|
||||
|
||||
### Oakland und das zweite Dojo (1964)
|
||||
Umzug nach Oakland. Zweite Schule. Kontroverse mit traditioneller chinesischer Kampfkunst-Gemeinde. Herausforderungskampf gegen Wong Jack Man (umstrittener Ausgang). Bruce gewinnt, aber unzufrieden mit eigener Performance. Beginn der JKD-Entwicklung.
|
||||
|
||||
### Long Beach International Karate Championships (1964)
|
||||
Demonstration revolutioniert amerikanische Kampfkunst-Szene. One-Inch-Punch (Ein-Zoll-Schlag) begeistert Publikum. Two-Finger-Pushups. Produzent Jay Sebring entdeckt ihn. Weg nach Hollywood beginnt.
|
||||
|
||||
### Kato in "The Green Hornet" (1966-1967)
|
||||
TV-Serie, Bruce spielt Sidekick Kato. Serie floppt nach einer Staffel. Aber: Bruce wird "Kato" für Amerika. Kann nicht von Typcasting wegkommen. Hollywood bietet nur stereotype Rollen.
|
||||
|
||||
### Privatlehrer der Stars
|
||||
Unterrichtet Hollywood-Prominenz: Steve McQueen, James Coburn, Kareem Abdul-Jabbar, Roman Polanski. Verdient gut. Frustriert von fehlenden Hauptrollen. Rassismus Hollywoods: Asiate nicht als Held vorstellbar.
|
||||
|
||||
### "Kung Fu" - Die gestohlene Rolle
|
||||
Entwickelt Konzept für Serie über Shaolin-Mönch im Wilden Westen. Warner Brothers interessiert. Bruce soll Star sein. Stattdessen: David Carradine (weiß) bekommt Rolle. Bruce tief verletzt. Entscheidet: Zurück nach Hongkong.
|
||||
|
||||
## Hongkong: Der Durchbruch (1971-1973)
|
||||
|
||||
### "The Big Boss" (Fist of Fury, 1971)
|
||||
Rückkehr nach Hongkong. Low-Budget-Film mit Golden Harvest. Sensation! Bricht alle Kassenrekorde. Bruce Lee über Nacht Megastar in Asien.
|
||||
|
||||
### "Fist of Fury" (1972)
|
||||
Zweiter Film. Noch größerer Erfolg. Chinesischer Nationalist gegen japanische Unterdrücker. Politisch aufgeladen. Hongkong-Kinos überfüllt. Hysterie.
|
||||
|
||||
### "Way of the Dragon" (1972)
|
||||
Bruce schreibt, regie
|
||||
|
||||
rt, choreographiert, und spielt Hauptrolle. Erste komplett selbst kontrollierte Produktion. Kolosseum-Kampf gegen Chuck Norris (Höhepunkt). Noch ein Rekordbrecher.
|
||||
|
||||
### Rückkehr nach Hollywood als Star
|
||||
Warner Brothers bemerkt Erfolg. Hollywood will Bruce jetzt. "Enter the Dragon" (1973) - erste Hollywood-Hongkong-Koproduktion. Großes Budget. Internationaler Release.
|
||||
|
||||
### "Enter the Dragon" (1973)
|
||||
Fertiggestellt Monate vor Tod. Veröffentlicht nach Tod (August 1973). Weltweit Kassenerfolg. Macht Kampfkunst-Filme global. Bruce posthumer Superstar. Film-Klassiker.
|
||||
|
||||
## Jeet Kune Do - Die Philosophie
|
||||
|
||||
### "Der Weg der abfangenden Faust"
|
||||
Entwickelt ab Mitte 1960er. Jeet Kune Do (JKD): "Intercepting Fist Way". Keine feste Stilform. Prinzip: "Using no way as way, having no limitation as limitation."
|
||||
|
||||
### Grundprinzipien
|
||||
- **Einfachheit**: Direkte, effiziente Bewegungen
|
||||
- **Direktheit**: Kürzester Weg zum Ziel
|
||||
- **Persönlicher Ausdruck**: Jeder entwickelt eigenen Stil
|
||||
- **Wissenschaftlich**: Basiert auf Biomechanik, nicht Tradition
|
||||
- **"Absorb what is useful"**: Nimm, was funktioniert, aus allen Stilen
|
||||
|
||||
### Ablehnung der Tradition
|
||||
Kritik an klassischen Kampfkünsten:
|
||||
- Zu ritualisiert
|
||||
- Unrealistisch im echten Kampf
|
||||
- Formgebunden
|
||||
- "Classical mess"
|
||||
|
||||
### Philosophischer Einfluss
|
||||
- Taoismus (Wasser-Prinzip)
|
||||
- Zen-Buddhismus
|
||||
- Krishnamurti (Freiheit von Konditionierung)
|
||||
- Westliche Philosophie (Studium)
|
||||
|
||||
### "Be Water, My Friend"
|
||||
Berühmteste Metapher:
|
||||
> "Be like water. Water can flow or it can crash. Be water, my friend."
|
||||
|
||||
Anpassungsfähigkeit als höchste Tugend.
|
||||
|
||||
## Der Philosoph-Krieger
|
||||
|
||||
### Studium und Lektüre
|
||||
Philosophie-Student an UW. Begeisterung für:
|
||||
- Jiddu Krishnamurti (größter Einfluss)
|
||||
- Alan Watts (Zen)
|
||||
- Lao Tzu (Taoismus)
|
||||
- Spinoza
|
||||
- Hegel (Dialektik)
|
||||
|
||||
### Eigene Notizen
|
||||
Tausende Seiten Notizen. Mixing von Kampfkunst und Philosophie. Skizzen, Diagramme, Zitate, Reflexionen. Nach Tod als Bücher veröffentlicht ("Tao of Jeet Kune Do", "Striking Thoughts").
|
||||
|
||||
### Zen und Kampfkunst
|
||||
No-Mind (Mushin): Handeln ohne Denken. Spontaneität. Unmittelbarkeit. Kampf als Meditation.
|
||||
|
||||
### Selbstverwirklichung
|
||||
"Honestly expressing yourself" zentral. Authentizität wichtiger als Technik. Kampfkunst als Weg zur Selbsterkenntnis.
|
||||
|
||||
## Training und Körper
|
||||
|
||||
### Obsessives Training
|
||||
Trainierte 7 Tage/Woche, mehrere Stunden täglich:
|
||||
- Kung Fu (Technik)
|
||||
- Gewichtheben (Kraft)
|
||||
- Cardio (Ausdauer)
|
||||
- Flexibilität (Stretching)
|
||||
- Ernährung (Protein-Shakes, Supplemente)
|
||||
|
||||
### Körperliche Perfektion
|
||||
- 141 Pfund (64 kg) bei 5'7" (1,70m)
|
||||
- Körperfett: ~5-7%
|
||||
- Unglaubliche Definition
|
||||
- Geschwindigkeit: Schläge zu schnell für normale Film-Kameras
|
||||
- One-Inch-Punch: 300+ Pfund Kraft
|
||||
|
||||
### Verletzung (1970)
|
||||
Rückenverletzung durch Übertraining (Good Morning Exercise ohne Aufwärmen). Ärzte: "Nie wieder Kampfkunst." Bruce ignoriert. Monatelange Schmerzen. Trainiert sich zurück. Noch intensiver danach.
|
||||
|
||||
### Nahrungsergänzung
|
||||
Pionier in Sport-Ernährung:
|
||||
- Protein-Shakes
|
||||
- Vitamine
|
||||
- Ginseng
|
||||
- Experimente mit allem (auch Problematisches)
|
||||
|
||||
## Der mysteriöse Tod (20. Juli 1973)
|
||||
|
||||
### Letzte Wochen
|
||||
Arbeitet an "Game of Death" (unvollendet). Meetings für neue Projekte. Auf Höhepunkt der Karriere. Gesundheitliche Probleme (Kopfschmerzen, Kollaps am Set Wochen zuvor).
|
||||
|
||||
### Der Tag
|
||||
20. Juli 1973, Hongkong. Meeting mit Produzent Raymond Chow. Dann zu Betty Ting Pei (Schauspielerin, Geliebte?) Apartment. Kopfschmerzen. Nimmt Equagesic (Schmerzmittel). Legt sich hin.
|
||||
|
||||
### Tod
|
||||
Wacht nicht auf. Notarzt gerufen. Krankenhaus: "Dead on Arrival." 32 Jahre alt.
|
||||
|
||||
### Offizielle Todesursache
|
||||
"Tod durch Misadventure": Allergische Reaktion auf Equagesic (enthält Aspirin und Muskelrelaxans). Gehirn-Ödem. Kontroverser Autopsiebericht.
|
||||
|
||||
### Verschwörungstheorien
|
||||
Bis heute:
|
||||
- Fluch (Sohn Brandon starb auch jung, 1993)
|
||||
- Mafia/Triaden
|
||||
- Kampfkunst-Rivalen
|
||||
- Gift
|
||||
- Energetisches Ungleichgewicht (zu viel Yang)
|
||||
|
||||
Wahrscheinlich: Kombination aus Medikament, Hitze, Überanstrengung, ev. Vorerkrankung.
|
||||
|
||||
### Beerdigung
|
||||
Seattle, Lake View Cemetery. 25.000 Menschen. Steve McQueen, James Coburn trugen Sarg. Linda im schwarzen Schleier mit Kindern.
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
### Film-Revolution
|
||||
- Erste asiatische Actionstar in Hollywood
|
||||
- Kampfkunst-Genre global
|
||||
- Beeinflusste: Jackie Chan, Jet Li, Donnie Yen, Matrix, Kill Bill
|
||||
- Stereotypen durchbrochen (teilweise)
|
||||
|
||||
### Kampfkunst-Revolution
|
||||
- Mixed Martial Arts (MMA) Vorläufer
|
||||
- Cross-Training normalisiert
|
||||
- Realismus vor Tradition
|
||||
- Jeet Kune Do weltweit praktiziert
|
||||
|
||||
### Popkultur-Ikone
|
||||
- Gelber Trainingsanzug (Kill Bill)
|
||||
- Nunchaku
|
||||
- Schrei/Battle Cry
|
||||
- Philosophische Zitate
|
||||
- Unzählige Parodien und Hommagen
|
||||
|
||||
### Kinder
|
||||
- **Brandon Lee** (1965-1993): Schauspieler, starb bei Drehunfall ("The Crow")
|
||||
- **Shannon Lee** (1969-): Schauspielerin, Produzentin, verwaltet Erbe
|
||||
|
||||
### Kontroversen im Vermächtnis
|
||||
- Authentizität vs. Selbsterfindung
|
||||
- Gewaltverherrlichung?
|
||||
- Kulturelle Aneignung oder Brückenbau?
|
||||
- "White Savior" in umgekehrter Form?
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Be like water, my friend."
|
||||
|
||||
> "Ich fürchte nicht den Mann, der 10.000 Kicks einmal geübt hat, aber ich fürchte den Mann, der einen Kick 10.000 mal geübt hat."
|
||||
|
||||
> "Knowing is not enough, we must apply. Willing is not enough, we must do."
|
||||
|
||||
> "The successful warrior is the average man, with laser-like focus."
|
||||
|
||||
> "Absorb what is useful, discard what is not, add what is uniquely your own."
|
||||
|
||||
> "Mistakes are always forgivable, if one has the courage to admit them."
|
||||
|
||||
## Das Phänomen Bruce Lee
|
||||
|
||||
Bruce Lee bleibt über 50 Jahre nach seinem Tod eine globale Ikone:
|
||||
|
||||
- **Kampfkunst-Revolutionär**: Befreite Martial Arts vom Dogma
|
||||
- **Filmstar**: Durchbrach rassistische Barrieren
|
||||
- **Philosoph**: Blend von Ost und West
|
||||
- **Kultfigur**: Symbol für Selbstbestimmung
|
||||
|
||||
Seine Größe lag in der Verbindung scheinbar unvereinbarer Welten: Tradition und Innovation, Ost und West, Körper und Geist, Kampf und Philosophie. Er lebte, was er predigte: authentischer Selbstausdruck ohne Grenzen.
|
||||
|
||||
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
|
||||
*16. April 1889 - 25. Dezember 1977*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Sir Charles Spencer Chaplin war ein britischer Schauspieler, Regisseur, Komponist und Komiker, der zur Ikone der Stummfilm-Ära wurde. Seine Figur des "Tramp" mit Melone, Gehstock und Watschelgang gehört zu den bekanntesten Gestalten der Filmgeschichte. Als genialer Pantomime und scharfsichtiger Sozialkritiker schuf er Meisterwerke wie "Modern Times" und "The Great Dictator". Chaplins Leben war geprägt von künstlerischem Triumph und persönlichen Tragödien, politischer Verfolgung und schließlich weltweiter Ehrung.
|
||||
|
||||
## Elend in London (1889-1913)
|
||||
|
||||
### Music Hall Eltern
|
||||
Geboren in Walworth, London. Vater Charles Chaplin Sr.: Music Hall-Sänger, Alkoholiker. Mutter Hannah Hill: Sängerin "Lily Harley". Beide auf Bühne mäßig erfolgreich. Ehe scheitert früh.
|
||||
|
||||
### Mutters Zusammenbruch
|
||||
Hannah verliert Stimme auf Bühne. Fünfjähriger Charlie muss weitersingen. Erste "Performance". Mutter mentale Krankheit (evtl. Syphilis). Immer wieder in Irrenanstalten. Traumatische Trennungen.
|
||||
|
||||
### Armut und Waisenhäuser
|
||||
Leben in bitterster Armut. Workhouses (Arbeitshäuser). Lambeth-Viertel: Slums. Hunger, Kälte, Angst. Prägt lebenslang. Tramp-Figur entstammt dieser Erfahrung.
|
||||
|
||||
### Tod des Vaters (1901)
|
||||
Vater stirbt mit 37 an Alkoholismus. Charlie 12 Jahre alt. Mutter endgültig in Anstalt. Charlie und Halbbruder Sydney auf sich gestellt.
|
||||
|
||||
### Music Hall und Pantomime
|
||||
Mit 8 Jahren Auftritt in "Eight Lancashire Lads". Lernt Tanz, Pantomime, Timing. Naturtalent. Mit 14 bei Fred Karno's Comedy Company - beste Pantomime-Truppe Englands. Spielt Betrunkene, Dandys, Tölpel.
|
||||
|
||||
## Hollywood-Aufstieg (1913-1923)
|
||||
|
||||
### Amerika-Tournee (1910, 1912)
|
||||
Mit Karno-Truppe nach USA. Vaudeville-Shows. Großer Erfolg. Mack Sennett (Keystone Studios) sieht Charlie.
|
||||
|
||||
### Keystone Studios (1914)
|
||||
Erster Filmvertrag: 150 $/Woche. Lernt Filmmedium. 35 Kurzfilme in einem Jahr. Entwickelt "Tramp"-Figur: zu große Hose, zu kleine Jacke, Melone, Schnurrbart, Gehstock, Watschelgang.
|
||||
|
||||
### "The Tramp" entsteht
|
||||
Zweiter Film: "Kid Auto Races at Venice" (1914). Erste Erscheinung des Landstreichers. Sofort erkennbar, unvergesslich. Wird über Nacht weltberühmt.
|
||||
|
||||
### Essanay, Mutual, First National
|
||||
Springt zwischen Studios. Gehälter explodieren:
|
||||
- 1915: 1.250 $/Woche (Essanay)
|
||||
- 1916: 670.000 $/Jahr (Mutual) - höchstbezahlter Entertainer der Welt
|
||||
- 1918: 1 Million $ + Bonus (First National)
|
||||
|
||||
### Künstlerische Kontrolle
|
||||
Mit steigendem Erfolg fordert totale Kontrolle: Regie, Schnitt, Drehbuch, Musik. Perfektionist. Dutzende Takes. Studios widerstehen zunächst, geben nach.
|
||||
|
||||
### Weltstar mit 25
|
||||
Charlie Chaplin-Manie: Merchandising, Imitatoren, Cartoons. Beliebtester Mensch der Welt. Stummfilm-Ära: Chaplin ist König.
|
||||
|
||||
### United Artists (1919)
|
||||
Mitbegründung mit Mary Pickford, Douglas Fairbanks, D.W. Griffith. Eigene Produktionsfirma. Vollständige Unabhängigkeit. Revolutionär in Hollywood.
|
||||
|
||||
## Die Meisterwerke (1921-1940)
|
||||
|
||||
### "The Kid" (1921)
|
||||
Erster Langfilm. Autobiographisch: Tramp adoptiert Waisenkind (Jackie Coogan). Mischung aus Slapstick und Sentimentalität. Publikum weint und lacht. Riesenerfolg.
|
||||
|
||||
### "The Gold Rush" (1925)
|
||||
Meisterwerk der Stummfilm-Ära. Alaska-Goldgräber. Legendäre Szenen:
|
||||
- Schuhe essen
|
||||
- Brötchen-Tanz
|
||||
- Cabin am Abgrund
|
||||
Perfektes Timing. Tragikomik. Bis heute frisch.
|
||||
|
||||
### "City Lights" (1931)
|
||||
"Ton-Film" ohne Dialog (nur Musik). Trotz Talkies an Stummfilm festgehalten. Blinde Blumenverkäuferin. Boxkampf-Szene. Ende: Millionen Tränen. Chaplin: "City Lights" sein bester Film. Einstein, Churchill liebten ihn.
|
||||
|
||||
### "Modern Times" (1936)
|
||||
Sozialkritik an Industrialisierung. Fließband-Arbeit unmenschlich. Chaplin als Zahnrad im Getriebe. "Feeding Machine"-Szene. Letztes Erscheinen des Tramps. Musik: Chaplin komponiert. Anklänge an Kommunismus - politische Probleme beginnen.
|
||||
|
||||
### "The Great Dictator" (1940)
|
||||
Erster Vollton-Film. Parodie auf Hitler ("Adenoid Hynkel"). Chaplin spielt jüdischen Barbier UND Diktator. Mutig: USA noch neutral. Schlussrede: 6 Minuten direkter Appell an Menschlichkeit. Kontrovers. Kommerziell erfolgreich. Heute: Klassiker.
|
||||
|
||||
## Persönliches Leben - Skandale (1918-1952)
|
||||
|
||||
### Junge Frauen
|
||||
Lebenslanges Muster: Wesentlich jüngere Frauen/Teenager. Heute: Problematisch. Damals: Auch kontrovers.
|
||||
|
||||
**Mildred Harris** (1918-1920): Heirat mit 16 (er 29). Unglücklich. Scheidung.
|
||||
|
||||
**Lita Grey** (1924-1927): Heirat mit 16 (er 35). Zwei Söhne: Charles Jr., Sydney. Bittere Scheidung. Skandal-Scheidungsklage (sexuelle Details). Öffentlichkeit geschockt. Image beschädigt.
|
||||
|
||||
**Paulette Goddard** (1936-1942): Schauspielerin, "Modern Times" Star. Geheimheirat. Glücklichste Beziehung. Scheidung freundschaftlich.
|
||||
|
||||
**Oona O'Neill** (1943-1977): Tochter des Dramatikers Eugene O'Neill. Heirat: Sie 18, er 54. Acht Kinder. Glücklich bis Tod. Oona: "Liebe meines Lebens."
|
||||
|
||||
### Joan Barry Vaterschaftsklage (1943)
|
||||
Schauspielerin behauptet Vaterschaft. Bluttests beweisen: Nicht Vater. Jury spricht trotzdem Vaterschaft zu (Vorurteil). Alimentezahlung. Rufschädigung.
|
||||
|
||||
### FBI-Überwachung
|
||||
J. Edgar Hoover lässt überwachen. "Kommunistenverdacht". Akte über 2000 Seiten. Privatleben ausgespäht. Politische Verfolgung.
|
||||
|
||||
## Politische Verfolgung (1940-1952)
|
||||
|
||||
### "The Great Dictator" und Antifaschismus
|
||||
Film gegen Hitler macht ihn verdächtig. Unterstützung für Sowjetunion (WW2-Alliierte!). Reden für "Second Front" (Hilfe für UdSSR).
|
||||
|
||||
### McCarthy-Ära
|
||||
Kalter Krieg. Rote Angst. Hollywood-Blacklist. Chaplin nie kommunistisch, aber links-liberal. Weigert sich zu distanzieren.
|
||||
|
||||
### Limelight-Premiere und Verbannung (1952)
|
||||
"Limelight" fertig. Chaplin reist mit Familie nach London für Premiere. Unterwegs: US-Justizministerium entzieht Wiedereinreise-Erlaubnis. "Moral Turpitude" (moralische Verwerflichkeit). Faktisch verbannt.
|
||||
|
||||
### Entscheidung: Schweiz
|
||||
Chaplin wütend, verletzt. Entscheidet, nicht zurückzukehren. Zieht nach Corsier-sur-Vevey, Schweiz (Villa "Manoir de Ban"). Lebt dort bis Tod.
|
||||
|
||||
## Schweizer Jahre (1952-1977)
|
||||
|
||||
### "Ein König in New York" (1957)
|
||||
Bittere Satire auf McCarthy-Amerika. Kommunisten-Jagd. Konsumgesellschaft. Nicht in USA gezeigt bis 1973. Persönlichster Film.
|
||||
|
||||
### "Die Gräfin von Hongkong" (1967)
|
||||
Letzter Film. Sophia Loren, Marlon Brando. Kritiker vernichten. Kommerzieller Flop. Enttäuschung. Nie wieder Regie.
|
||||
|
||||
### "Meine Autobiographie" (1964)
|
||||
Best
|
||||
|
||||
seller. Offene Darstellung armer Kindheit. Liebesbeziehungen verharmlost. Politisches verteidigt.
|
||||
|
||||
### Versöhnung mit Amerika (1972)
|
||||
Academy Awards - Special Oscar "für unschätzbaren Beitrag zum Film". Rückkehr nach 20 Jahren. Standing Ovation. Tränen. Oona an seiner Seite. Vergeben und vergessen (teilweise).
|
||||
|
||||
### Ritterschlag (1975)
|
||||
Queen Elizabeth II.: Knight Commander of the British Empire. Sir Charles Chaplin. Von Slumkind zum Ritter.
|
||||
|
||||
### Tod (1977)
|
||||
Weihnachten, 25. Dezember, 88 Jahre alt. Im Schlaf, friedlich. Beerdigung in Vevey. Oona überlebte bis 1991.
|
||||
|
||||
### Leichenraub (1978)
|
||||
Bizarr: Leiche gestohlen, Lösegeld gefordert. Körper gefunden. Täter gefasst. Oona: "Charlie hätte gelacht." Grab nun diebstahlsicher.
|
||||
|
||||
## Künstlerische Genialität
|
||||
|
||||
### Pantomime-Genie
|
||||
Körperbeherrschung wie Balletttänzer. Timing perfekt. Jede Geste spricht. Musik ohne Ton.
|
||||
|
||||
### Komposition
|
||||
Komponierte Musik für alle Filme. "Smile" (aus "Modern Times"): Hit-Song. Emotional, melodisch.
|
||||
|
||||
### Regie und Schnitt
|
||||
Totale Kontrolle. Hunderte Takes. Perfektionist bis Tyrannei. Aber: Resultate rechtfertigen.
|
||||
|
||||
### Sozialkritik
|
||||
Tramp als Underdog. Kritik an:
|
||||
- Kapitalismus
|
||||
- Industrialisierung
|
||||
- Militarismus
|
||||
- Ungerechtigkeit
|
||||
Nie plump. Immer menschlich.
|
||||
|
||||
## Der Tramp
|
||||
|
||||
### Ikonische Figur
|
||||
Melone + Bambusstock + Schnurrbart + Watschelgang = Instant Recognition. Optimist trotz Armut. Würde in Lumpen. Gentleman trotz Hunger. Komisch UND tragisch.
|
||||
|
||||
### Universelle Identifikation
|
||||
Stummfilm = keine Sprachbarriere. Tramp verstanden von China bis Chile. Underdog der Welt.
|
||||
|
||||
### Abschied
|
||||
"Modern Times" (1936): Letzter Auftritt. Geht in Sonnenuntergang mit Paulette Goddard. "Smile!" Ikonisches Ende.
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Ein Tag ohne Lachen ist ein verlorener Tag."
|
||||
|
||||
> "Das Leben ist eine Tragödie, wenn man es in der Nahaufnahme betrachtet, aber eine Komödie in der Totale."
|
||||
|
||||
> "Du wirst den Regenbogen niemals finden, wenn du nach unten schaust."
|
||||
|
||||
> "Man muss die Dinge nicht zu ernst nehmen - besonders sich selbst nicht."
|
||||
|
||||
> "Ich glaube an das Lachen und das Weinen, an das Leid und das Mitleid."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Charlie Chaplin bleibt der größte Komiker der Filmgeschichte:
|
||||
|
||||
- **Universalgenie**: Schauspieler, Regisseur, Komponist, Autor
|
||||
- **Der Tramp**: Unsterbliche Figur der Popkultur
|
||||
- **Sozialkritiker**: Humanist mit Herz für Underdog
|
||||
- **Künstlerische Integrität**: Opferte Komfort für Vision
|
||||
|
||||
Seine Filme sind zeitlos - "City Lights" und "Modern Times" bewegen heute wie vor 90 Jahren. Die Kombination aus Slapstick und Pathos, Komik und Tragik, bleibt unerreicht.
|
||||
|
||||
Von den Londoner Slums zum Weltstar, von Hollywood-Liebling zum politischen Flüchtling, von Verbanntem zum geehrten Ritter - Chaplins Leben war selbst ein Film. Seine Botschaft: Lachen und Mitgefühl sind revolutionär. Die kleinen Leute zählen. Menschlichkeit siegt über Maschinen.
|
||||
|
||||
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
|
||||
*16. Oktober 1886 - 1. Dezember 1973*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
David Ben-Gurion, geboren als David Grün, war der erste Ministerpräsident Israels und gilt als Hauptarchitekt des jüdischen Staates. Als charismatischer Führer und pragmatischer Visionär führte er die zionistische Bewegung durch entscheidende Jahre: Von der Immigration nach Palästina über die Staatsgründung 1948 bis zum Aufbau einer modernen Nation. Seine Persönlichkeit prägte Israel fundamental - als Sozialist und Nationalist, Idealist und Realist, Prophet und Politiker zugleich.
|
||||
|
||||
## Polen: Die Wurzeln (1886-1906)
|
||||
|
||||
### Plonsk
|
||||
Geboren als David Grün in Plonsk (damals Russisches Reich, heute Polen). Vater Avigdor Grün: Jurist, Anhänger der Hovevei Zion (Zionsfreunde). Mutter Scheindel starb, als David 11 war. Prägend: Antisemitismus, Pogrome, Diskriminierung.
|
||||
|
||||
### Zionistisches Elternhaus
|
||||
Aufwachsen in zionistischer Atmosphäre. Vater Mitbegründer der zionistischen Organisation in Plonsk. David mit 14 Jahren Gründer von "Ezra" - Jugendorganisation für Hebräisch und Zionismus. Hebräisch statt Jiddisch: Ideologisch wichtig.
|
||||
|
||||
### Träume von Palästina
|
||||
Liest Herzl. Träumt von jüdischem Staat. Beschließt Aliyah (Einwanderung). Lehnt russische Universität ab. Nicht Assimilation, sondern eigener Staat.
|
||||
|
||||
## Zweite Aliyah nach Palästina (1906-1918)
|
||||
|
||||
### Ankunft in Jaffa (1906)
|
||||
20-jährig mit wenig Geld. Zweite Aliyah (1904-1914): Idealistische Pioniere. David arbeitet als Landarbeiter. Orangen pflücken. Malaria. Harte körperliche Arbeit. Aber: Erfüllung. Jude auf jüdischem Land.
|
||||
|
||||
### Poale Zion - Arbeiterzionismus
|
||||
Tritt Poale Zion bei (Arbeiter Zions). Sozialistischer Zionismus:
|
||||
- Jüdischer Staat UND Sozialismus
|
||||
- Arbeiter als Avantgarde
|
||||
- Landwirtschaft als Ideal (nicht Luftmenschen des Schtetl)
|
||||
- Hebräische Arbeiterklasse schaffen
|
||||
|
||||
### Name "Ben-Gurion" (1910)
|
||||
Hebräisiert Namen von David Grün zu David Ben-Gurion. "Sohn des Löwen" - nach Yosef Ben Gurion, Anführer im jüdischen Aufstand gegen Rom (1. Jh.). Symbolisch: Neuer Mensch, neue Identität.
|
||||
|
||||
### Journalismus und Agitation
|
||||
Editor von "Ahdut" (Einheit), Zeitung der Poale Zion. Beredt und kämpferisch. Talentierter Redner. Organisator. Aufstieg in Arbeiterbewegung.
|
||||
|
||||
### Istanbul und Rechtsstudium (1912-1914)
|
||||
Studiert Jura in Istanbul (damals Teil des Osmanischen Reichs). Ziel: Palästina von innen reformieren. Aber: Osmanisches Reich und Zionismus schwer vereinbar.
|
||||
|
||||
### Verbannung (1915)
|
||||
Erster Weltkrieg. Osmanische Behörden misstrauisch gegen Zionisten. Ben-Gurion und Yitzhak Ben-Zvi deportiert nach Ägypten, dann USA. Drei Jahre Exil.
|
||||
|
||||
### New York (1915-1918)
|
||||
Propaganda für Zionismus. Gründet Hechalutz (Pionier-Organisation). Rekrutierung für Jüdische Legion (britische Armee). Heirat mit Paula Munweis (1917) - lebenslange Ehe, drei Kinder. Paula: Krankenschwester, Fels in Brandung.
|
||||
|
||||
## Aufbau des Yishuv (1918-1947)
|
||||
|
||||
### Rückkehr als Soldat (1918)
|
||||
Mit Jüdischer Legion zurück nach Palästina. Britisches Mandat beginnt. Balfour-Deklaration (1917): Hoffnung auf "national home for Jewish people".
|
||||
|
||||
### Histadrut (1920)
|
||||
Gründung der Histadrut - Generalföderation jüdischer Arbeiter. Nicht nur Gewerkschaft: Soziale Bewegung, Krankenkassen, Schulen, Unternehmen. Ben-Gurion Generalsekretär bis 1935. Machtbasis.
|
||||
|
||||
### Mapai-Partei (1930)
|
||||
Vereinigung sozialistischer Parteien zur Mapai (Arbeiterpartei). Ben-Gurion Führer. Dominiert politisches Leben des Yishuv (jüdische Gemeinschaft in Palästina) bis Staatsgründung.
|
||||
|
||||
### Jewish Agency (1935)
|
||||
Chairman der Jewish Agency (Quasi-Regierung des Yishuv unter Mandat). Verhandlungen mit Briten. Internationale Diplomatie. Palästinische Araber. Innerz
|
||||
|
||||
ionistische Politik. Zentrale Figur.
|
||||
|
||||
### Pragmatismus vs. Maximalismus
|
||||
Kritisiert von Revisionisten (Jabotinsky): Zu kompromissbereit. Ben-Gurion: "Realpolitik" - nehmen was möglich, ausbauen. Teilung akzeptieren, dann erweitern. Langfristige Strategie.
|
||||
|
||||
### Arabische Aufstände (1936-1939)
|
||||
Gewaltsame arabisch-palästinensische Revolte gegen Juden und Briten. Hunderte Tote. Ben-Gurion: Aufbau Haganah (jüdische Untergrund-Armee). Selbstverteidigung notwendig. Aber auch: Verständnis für arabische Ängste (in Privat).
|
||||
|
||||
### Weißbuch (1939)
|
||||
Britische Einschränkung jüdischer Immigration. Katastrophe vor Holocaust. Ben-Gurion wütend. "Wir werden gegen Hitler kämpfen, als gäbe es kein Weißbuch, und gegen das Weißbuch kämpfen, als gäbe es keinen Hitler."
|
||||
|
||||
### Holocaust und Immigration
|
||||
Während Holocaust verzweifelte Versuche, Juden zu retten. "Aliyah Bet" - illegale Immigration trotz Weißbuch. Schiffe mit Flüchtlingen. Briten abweisen. Machtlosigkeit quält Ben-Gurion lebenslang.
|
||||
|
||||
## Staatsgründung (1947-1948)
|
||||
|
||||
### UN-Teilungsplan (1947)
|
||||
UN-Generalversammlung stimmt für Teilung Palästinas: Jüdischer und arabischer Staat. Ben-Gurion akzeptiert trotz Mängel. Pragmatismus: Staat jetzt, Grenzen später.
|
||||
|
||||
### Bürgerkrieg (1947-1948)
|
||||
Sofort Gewalt. Palästinensische Milizen gegen jüdische Siedlungen. Haganah kämpft. Ben-Gurion bereitet Staatsgründung vor trotz Krieg.
|
||||
|
||||
### 14. Mai 1948 - Unabhängigkeitserklärung
|
||||
Tel Aviv Museum. Ben-Gurion liest Unabhängigkeitserklärung:
|
||||
> "Der Staat Israel ist gegründet!"
|
||||
|
||||
Tränen, Jubel. 2000 Jahre Traum erfüllt. Nächster Tag: Arabische Armeen greifen an.
|
||||
|
||||
### Unabhängigkeitskrieg (1948-1949)
|
||||
Israel gegen Ägypten, Jordanien, Syrien, Irak, Libanon. Ben-Gurion: Verteidigungsminister und Premierminister. Mikromanagement. Entscheidungen über Taktik. Kontrovers: Harte Befehle (Dörfer, Vertreibungen). Israel überlebt. Waffenstillstand 1949.
|
||||
|
||||
### Nakba
|
||||
Für Palästinenser: Katastrophe. 700.000 Flüchtlinge. Dörfer zerstört. Ben-Gurion Politik: Rückkehr verhindern. Demografische Sicherheit. Moralisch komplex. Schatten über Gründung.
|
||||
|
||||
## Premierminister und Staatsbauer (1948-1963)
|
||||
|
||||
### Erste Regierung (1948-1954)
|
||||
Aufbau aus Nichts:
|
||||
- Massenimmigration (Holocaust-Überlebende, orientalische Juden)
|
||||
- Ma'abarot (Übergangslager) für Immigranten
|
||||
- IDF (Israel Defense Forces) aus Untergrundorganisationen
|
||||
- Wirtschaft, Infrastruktur, Institutionen
|
||||
|
||||
### "Mamlachtiyut" (Staatlichkeit)
|
||||
Ben-Gurions Doktrin: Staat über Partei. Armee unpolitisch. Zentralisierung. Autorität. Kontrovers in demokratischem Rahmen.
|
||||
|
||||
### Lavon-Affäre (1954)
|
||||
Geheimdienstskandal. Sabotage in Ägypten. Ben-Gurion tritt zurück (1954). Zieht nach Sde Boker (Kibbuz in Negev). "Im Negev wird Israels Schicksal entschieden."
|
||||
|
||||
### Rückkehr (1955)
|
||||
Nach Wahlen zurück als Verteidigungsminister, dann PM. Lavon-Affäre verfolgt weiter. Interne Konflikte.
|
||||
|
||||
### Suez-Krise (1956)
|
||||
Geheimes Bündnis mit Frankreich, England gegen Nasser (Ägypten). Israel erobert Sinai. Internationaler Druck (USA, UdSSR). Rückzug. Aber: Militärisch erfolgreich. Abschreckung etabliert.
|
||||
|
||||
### Eichmann-Prozess (1961)
|
||||
Mossad kidnap
|
||||
|
||||
pt Adolf Eichmann aus Argentinien. Prozess in Jerusalem. Weltweite Aufmerksamkeit. Holocaust-Bewusstsein. Ben-Gurion: "Welt muss wissen." Eichmann hingerichtet 1962 (einzige Todesstrafe in Israel).
|
||||
|
||||
### Rücktritt (1963)
|
||||
Interne Parteistreitigkeiten (Lavon-Affäre, Generationenwechsel). Ben-Gurion resigniert. Levi Eshkol Nachfolger. Ben-Gurion verbittert. Spaltung mit Mapai.
|
||||
|
||||
## Letzte Jahre (1963-1973)
|
||||
|
||||
### Rafi-Partei (1965)
|
||||
Gründet Splitterpartei Rafi. Gegen Eshkol. Wahl-Flop. Isolation. Aber: Junge Anhänger (Moshe Dayan, Shimon Peres).
|
||||
|
||||
### Sechs-Tage-Krieg (1967)
|
||||
Nicht an Regierung beteiligt. Kritisiert Eshkol (unfair). Nach Sieg: Ambivalent über Eroberungen. Besetzte Gebiete: Sicherheit, aber demografische Gefahr.
|
||||
|
||||
### Rückzug nach Sde Boker
|
||||
Letztes Jahrzehnt in Negev-Kibbuz. Schreibt Memoiren. Liest Philosophie, Geschichte. Platon, Spinoza. Lernt Altgriechisch, Sanskrit. Universaler Geist.
|
||||
|
||||
### Tod (1973)
|
||||
1. Dezember 1973, 87 Jahre alt. Gehirnblutung. Staatsbeerdigung auf Mount Herzl, Jerusalem? Nein: Beerdigung in Sde Boker wie gewünscht. Millionen trauern.
|
||||
|
||||
## Persönlichkeit
|
||||
|
||||
### Charisma und Autorität
|
||||
Kleine Statur (1,62m), aber riesige Präsenz. Weiße Haarmähne. Intensive Augen. Hypnotischer Redner. Dominierte Raum. "Der Alte" genannt (schon mit 40).
|
||||
|
||||
### Pragmatischer Idealist
|
||||
Zionist UND Realist. Träume + Machopolitik. Kompromisse + Vision. "Unmögliches wird sofort erledigt, Wunder dauern etwas länger."
|
||||
|
||||
### Arbeitsethos
|
||||
14-Stunden-Tage. Workaholic. Mikromanagement. Erwartete gleiches von anderen. Rücksichtslos zu sich und anderen.
|
||||
|
||||
### Familie
|
||||
Paula Ben-Gurion: Leidende Ehefrau. David oft abwesend. Drei Kinder. Sohn in 1948-Krieg schwer verwundet. Familie Opfer für Staat.
|
||||
|
||||
### Widersprüche
|
||||
- Sozialist, aber autoritär
|
||||
- Demokrat, aber ungeduldig mit Opposition
|
||||
- Friedliebend (rhetorisch), aber Militarist
|
||||
- Säkular, aber biblisch inspiriert
|
||||
|
||||
## Ideologie und Vision
|
||||
|
||||
### Sozialistischer Zionismus
|
||||
Einzigartiger Mix:
|
||||
- Jüdischer Nationalismus
|
||||
- Sozialistisches Wirtschaftssystem
|
||||
- Pionier-Ethos (Chalutzim)
|
||||
- Kibbuzim als Ideal
|
||||
|
||||
### "Neuer Jude"
|
||||
Transformation vom passiven Diaspora-Juden zum aktiven Sabra:
|
||||
- Physisch stark (Arbeiter, Soldat)
|
||||
- Hebräisch sprechend
|
||||
- Landverbunden
|
||||
- Selbstverteidigung
|
||||
|
||||
### Bibel und Säkularismus
|
||||
Paradox: Säkular, aber biblisch. Bibel als nationales Epos, nicht religiös. Joshua, David, Maccabees als Vorbilder. Jüdisches Volk, nicht Religion.
|
||||
|
||||
### "Mamlachtiyut" (Staatlichkeit)
|
||||
Staat über allen Partikularinteressen. Armee überparteilich. Zentralisierung. Autorität. Demokratie, aber geführt.
|
||||
|
||||
## Kontroversen und Kritik
|
||||
|
||||
### Vertreibung der Palästinenser
|
||||
Plan Dalet (1948). Dörfer geräumt. Rückkehr verhindert. "Transfer"-Gedanken schon in 1930ern. Heute: Zentrale Schuldfrage. Ben-Gurion: Sicherheit + Demografie. Kritiker: Ethnische Säuberung.
|
||||
|
||||
### Mizrahi-Diskriminierung
|
||||
Orientalische Juden (aus arabischen Ländern): Zweitklassig behandelt. Sprühen mit DDT. Einfache Jobs. Kulturelle Arroganz europäischer Elite. Ben-Gurion Teil dessen.
|
||||
|
||||
### Autoritarismus
|
||||
Demokrat, aber intolerant gegen Dissens. Lavon-Affäre: Rücksichtslos. Innerpolitische Feinde.
|
||||
|
||||
### "Ohne Bibel keine Ansprüche"
|
||||
Säkularer Zionismus + biblische Ansprüche = Problematisch. Religiöse Nationalisten später missbrauchen.
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Wir werden gegen Hitler kämpfen, als gäbe es kein Weißbuch, und gegen das Weißbuch kämpfen, als gäbe es keinen Hitler."
|
||||
|
||||
> "Im Negev wird Israels Schicksal entschieden."
|
||||
|
||||
> "Unmöglich ist kein hebräisches Wort."
|
||||
|
||||
> "Wer nicht an Wunder glaubt, ist kein Realist."
|
||||
|
||||
> "Es spielt keine Rolle, was die Gojim sagen, es spielt nur eine Rolle, was die Juden tun."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
David Ben-Gurion bleibt der Staatsgründer Israels:
|
||||
|
||||
- **Architekt** des jüdischen Staates
|
||||
- **Führer** durch Unabhängigkeitskrieg
|
||||
- **Erbauer** der Institutionen
|
||||
- **Visionär** mit Pragmatismus
|
||||
|
||||
Seine Größe: Traum Wirklichkeit machen. Gegen alle Widerstände: Holocaust, britisches Empire, arabische Armeen, interne Zwiste - Israel entstand.
|
||||
|
||||
Seine Schwächen: Rücksichtslosigkeit, Autoritarismus, blinde Flecken gegenüber Palästinensern. Preise der Gründung bis heute spürbar.
|
||||
|
||||
Ohne Ben-Gurion kein Israel in 1948. Seine Willenskraft, strategische Intelligenz und charismatische Führung waren entscheidend. Er war der richtige Mann zur richtigen Zeit.
|
||||
|
||||
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
|
||||
*28. Juni 1712 - 2. Juli 1778*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Jean-Jacques Rousseau war ein Genfer Philosoph, Schriftsteller und Komponist der Aufklärung, dessen Ideen die Französische Revolution, die Romantik und die moderne politische Theorie nachhaltig prägten. Als Kritiker der Zivilisation und Prophet der Natürlichkeit entwickelte er die Ideen des Gesellschaftsvertrags, der Volkssouveränität und der natürlichen Erziehung. Sein Leben war geprägt von Widersprüchen: Ein Theoretiker der Erziehung, der seine eigenen Kinder ins Findelhaus gab; ein Verkünder der Einfachheit, der Paranoia verfiel; ein Philosoph der Freiheit, dessen Ideen Diktaturen inspirierten.
|
||||
|
||||
## Genf und frühe Wanderjahre (1712-1742)
|
||||
|
||||
### Geburt und Mutterlose Kindheit
|
||||
Geboren in Genf als Sohn des Uhrmachers Isaac Rousseau. Mutter Suzanne Bernard starb neun Tage nach Geburt im Kindbettfieber. Vater machte Jean-Jacques für Tod verantwortlich. Schuldgefühle prägten lebenslang.
|
||||
|
||||
### Frühe Bildung
|
||||
Vater las mit ihm sentimentale Romane und Plutarchs Heldenleben. Frühe Identifikation mit antiken Republikanern. Keine systematische Schulbildung. Autodidakt.
|
||||
|
||||
### Lehre und Flucht (1728)
|
||||
Lehre bei Graveur. Harte Behandlung. Mit 16 Jahren Flucht aus Genf (Stadttore geschlossen). Wanderte umher. Begegnung mit Madame de Warens - Wendepunkt.
|
||||
|
||||
### Madame de Warens (1728-1740)
|
||||
Louise de Warens: Konvertitin zum Katholismus, Agentin Savoyens. Nahm Jean-Jacques auf. "Maman" genannt. Wurde Geliebte (später). Les Charmettes bei Chambéry: Jahre des Lernens. Bibliothek. Musik. Philosophie. Glücklichste Zeit.
|
||||
|
||||
### Konversion
|
||||
Übertritt zum Katholizismus in Turin. Pragmatisch, nicht überzeugt. Verlor Genfer Bürgerrecht. Später zurück zum Calvinismus (1754).
|
||||
|
||||
### Wanderjahre
|
||||
Hauslehrer, Sekretär, Musiklehrer. Unbeständig. Hypochondrie. Autodidaktische Bildung: Descartes, Leibniz, Newton, Locke. Entwicklung eigenen Denkens.
|
||||
|
||||
## Paris und erste Erfolge (1742-1756)
|
||||
|
||||
### Ankunft in Paris (1742)
|
||||
Mit Notationssystem für Musik. Vorstellung bei Académie. Ablehnung. Aber: Entrée in Pariser Gesellschaft.
|
||||
|
||||
### Thérèse Levasseur (ab 1745)
|
||||
Beziehung mit Wäscherin. Ungebildet, aber treu. Nie geheiratet (bis 1768). Fünf Kinder - alle ins Findelhaus gegeben. Skandal. Rousseau rechtfertigt: Zu arm. Später: Lebenslanges Schuldbewusstsein.
|
||||
|
||||
### Freundschaft mit Diderot
|
||||
Diderot und Encyclopédistes. Rousseau schreibt Artikel über Musik. Teil der Aufklärungsszene. Aber: Bald Bruch.
|
||||
|
||||
### Première Discours (1750)
|
||||
Akademie Dijon: Preisfrage "Hat Fortschritt der Wissenschaften und Künste zur Läuterung der Sitten beigetragen?"
|
||||
|
||||
Rousseaus Antwort: **NEIN!**
|
||||
- Zivilisation korrumpiert
|
||||
- Natürliche Tugend verloren
|
||||
- Luxus schwächt
|
||||
- Künste dekadent
|
||||
|
||||
Sensation! Paradox in Aufklärungszeit. Diskussionsgewinner. Berühmt über Nacht.
|
||||
|
||||
### Second Discours (1755) - Über Ungleichheit
|
||||
"Discours sur l'origine et les fondements de l'inégalité parmi les hommes":
|
||||
- **Naturzustand**: "Edler Wilder" (bon sauvage)
|
||||
- Ursprünglich frei, gleich, gut
|
||||
- **Eigentum**: "Erster, der eingezäunt" = Unglück
|
||||
- Zivilisation = Entfremdung
|
||||
- Ungleichheit künstlich, nicht natürlich
|
||||
|
||||
Radikale Kritik. Skandal. Voltaire (später Feind): "Ich habe Lust, auf allen Vieren zu gehen."
|
||||
|
||||
### Diskographie
|
||||
Auch Komponist. Oper "Le Devin du Village" (1752). Erfolgreich. Aber: Lehnt Pension ab. Will unabhängig bleiben.
|
||||
|
||||
## Eremitage und Meisterwerke (1756-1762)
|
||||
|
||||
### Rückzug aufs Land
|
||||
Madame d'Épinay bietet Häuschen "Ermitage" in Montmorency (bei Paris). Rousseau zieht mit Thérèse. Einfaches Leben. Natur. Schreibt Hauptwerke.
|
||||
|
||||
### "Julie ou La Nouvelle Héloïse" (1761)
|
||||
Briefroman. Leidenschaftliche Liebe + Tugend. Bestseller! Sentimentalität. Empfindsamkeit. Vorbereitung Romantik. Frauen weinten beim Lesen.
|
||||
|
||||
### "Du contrat social" (1762) - Der Gesellschaftsvertrag
|
||||
Politische Philosophie:
|
||||
|
||||
**Kernideen:**
|
||||
- Mensch geboren frei, überall in Ketten
|
||||
- Gesellschaftsvertrag = Lösung
|
||||
- **Volonté générale** (Gemeinwille) ≠ Wille aller
|
||||
- Volkssouveränität
|
||||
- Repräsentation problematisch
|
||||
- **Kleine Republik** ideal
|
||||
- **Zivilreligion** notwendig
|
||||
|
||||
Inspirierte Französische Revolution. Aber auch: Jakobiner, Totalitarismus. Umstritten.
|
||||
|
||||
### "Émile ou De l'éducation" (1762)
|
||||
Erziehungsroman. Naturgemäße Erziehung:
|
||||
|
||||
**Prinzipien:**
|
||||
- Kind nicht kleiner Erwachsener
|
||||
- Entwicklungsstufen beachten
|
||||
- Lernen durch Erfahrung, nicht Bücher (außer Robinson Crusoe)
|
||||
- Natürliche Neugier fördern
|
||||
- Gegen Zwang
|
||||
- "Glaubensbekenntnis des savoyischen Vikars": Naturreligion
|
||||
|
||||
**Skandal:**
|
||||
- Buch verbrannt in Paris UND Genf
|
||||
- Haftbefehl gegen Rousseau
|
||||
- Kritik an Kirche
|
||||
- Kinder-ins-Findelhaus-Widerspruch
|
||||
|
||||
### Flucht (1762)
|
||||
Vor Verhaftung nach Yverdon (Schweiz), dann Môtiers (Neuenburg, preußisch). Verfolgung überall. Paranoia wächst.
|
||||
|
||||
## Verfolgung und Paranoia (1762-1770)
|
||||
|
||||
### Bruch mit Aufklärern
|
||||
Voltaire attackiert öffentlich (Kinder-Skandal). D'Alembert kritisiert. Diderot entfremdet. Rousseau fühlt sich von allen verraten.
|
||||
|
||||
### Steinigung in Môtiers (1765)
|
||||
Dorfbewohner werfen Steine auf Haus. Flüchtete auf Petersinsel (Bielersee). Idyll. Aber: Berner Obrigkeit weist aus.
|
||||
|
||||
### David Hume und England (1766-1767)
|
||||
Auf Einladung Humes nach England. Gastfreundschaft. Aber: Rousseau sieht überall Verschwörung. Beschuldigt Hume der Intrige. Spektakulärer Bruch. Rückkehr nach Frankreich (inkognito).
|
||||
|
||||
### Wahnvorstellungen
|
||||
Klassische Paranoia:
|
||||
- Alle gegen ihn
|
||||
- Komplotte überall
|
||||
- Verfolg
|
||||
|
||||
ungswahn
|
||||
- Aber: Manchmal real (Verbote, Haftbefehle)
|
||||
|
||||
Mischung aus realer Verfolgung und psychischer Krankheit.
|
||||
|
||||
## Letzte Jahre in Paris (1770-1778)
|
||||
|
||||
### Rückkehr
|
||||
Unter falschem Namen nach Paris. Offiziell geduldet. Lebt bescheiden. Notenkopieren als Einkommen.
|
||||
|
||||
### "Les Confessions" (1765-1770, posthum)
|
||||
Autobiographie. Revolutionär ehrlich:
|
||||
- Sexuelle Deviationen (Exhibitionismus, Masochismus)
|
||||
- Diebstähle
|
||||
- Kinder-Weggabe
|
||||
- Fehler, Schwächen
|
||||
|
||||
Präzedenzlos offenherzig. "Zeige ich mich, wie ich war: verächtlich und niedrig [...] gut, edel, erhaben." Begründer moderner Autobiographie.
|
||||
|
||||
### "Rêveries du promeneur solitaire" (1776-1778)
|
||||
"Träumereien eines einsamen Spaziergängers". Letzte Schrift. Melancholisch, pastoral. Sucht Frieden in Natur. Botanik als Trost. Schönste Prosa.
|
||||
|
||||
### Tod (1778)
|
||||
2. Juli, Schloss Ermenonville (Gast Marquis de Girardin). Schlaganfall. 66 Jahre alt. Beerdigung auf Île des Peupliers. 1794: Panthéon-Überführung (Revolution ehrt ihn). Grab gegenüber von Voltaire.
|
||||
|
||||
## Kernideen
|
||||
|
||||
### Naturzustand vs. Zivilisation
|
||||
- **Bon sauvage** (edler Wilder): Ursprünglich gut
|
||||
- Zivilisation = Korruption
|
||||
- Eigentum = Unglück
|
||||
- Entfremdung durch Gesellschaft
|
||||
- Aber: Kein zurück - nur vorwärts
|
||||
|
||||
### Volonté générale (Gemeinwille)
|
||||
- Unterschied: Gemeinwille ≠ Wille aller
|
||||
- Gemeinwohl vor Einzelinteresse
|
||||
- Souverän = Volk
|
||||
- Unveräußerlich
|
||||
- Problem: Deutungshoheit?
|
||||
|
||||
### Freiheit
|
||||
Paradoxe Formulierung:
|
||||
> "Der Mensch wird gezwungen, frei zu sein."
|
||||
|
||||
Freiheit = Gehorsam gegenüber selbst gegebenen Gesetzen. Positiver Freiheitsbegriff. Gefährlich interpretierbar.
|
||||
|
||||
### Natürliche Erziehung
|
||||
- Gegen Zwang
|
||||
- Entwicklungsstufen respektieren
|
||||
- Erfahrung > Bücher
|
||||
- Natürliche Neugier
|
||||
- Émile = Ideal (aber unrealistisch)
|
||||
|
||||
### Religion
|
||||
- Gegen organisierte Religion
|
||||
- Für natürliche Frömmigkeit
|
||||
- Gewissen als Stimme Gottes
|
||||
- Zivilreligion im Staat notwendig
|
||||
- Deistisch
|
||||
|
||||
## Wirkungsgeschichte
|
||||
|
||||
### Französische Revolution
|
||||
Robespierre: Rousseau-Verehrer. "Social Contract" als Bibel. Aber: Jakobinischer Terror im Namen des Gemeinwillens. Problematisches Erbe.
|
||||
|
||||
### Romantik
|
||||
Wegbereiter:
|
||||
- Emotionalität > Rationalität
|
||||
- Natur > Kultur
|
||||
- Innerlichkeit
|
||||
- Sentimentalität
|
||||
- Goethe, Schiller beeinflusst
|
||||
|
||||
### Pädagogik
|
||||
Pestalozzi, Fröbel, Montessori, Reformpädagogik: Alle Erben Rousseaus. Kindorientierte Erziehung. Entwicklungspsychologie.
|
||||
|
||||
### Politische Philosophie
|
||||
- Demokratietheorie
|
||||
- Volkssouveränität
|
||||
- Aber auch: Totalitarismus (Benjamin Constant kritisierte)
|
||||
- Republikanismus
|
||||
|
||||
### Autobiographie
|
||||
Confessions: Vorbild für alle späteren. Ehrlichkeit, Selbstoffenbarung. Goethe, Tolstoi, Proust.
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Der Mensch wird frei geboren, und überall ist er in Ketten."
|
||||
|
||||
> "Zurück zur Natur!"
|
||||
|
||||
> "Das Geld, das man besitzt, ist das Mittel zur Freiheit, dasjenige, dem man nachjagt, das Mittel zur Knechtschaft."
|
||||
|
||||
> "Man muss viel gelernt haben, um über das, was man nicht weiß, fragen zu können."
|
||||
|
||||
> "Gewissen! Göttlicher Instinkt, unsterbliche himmlische Stimme!"
|
||||
|
||||
## Widersprüche und Kritik
|
||||
|
||||
### Kinder ins Findelhaus
|
||||
Theoretiker der Erziehung gibt eigene Kinder weg. Unentschuldbar. Selbst eingestanden. Versuch der Rechtfertigung in Confessions. Glaubwürdigkeit beschädigt.
|
||||
|
||||
### Frauenbild
|
||||
Sophie (Émiles Partnerin): Untergeordnet, für Mann erzogen. Mary Wollstonecraft kritisierte. Rousseau: Progressiv UND reaktionär.
|
||||
|
||||
### Totalitarismus-Gefahr
|
||||
"Gemeinwille" zweideutig. Kann Minderheiten unterdrücken. "Gezwungen frei zu sein": Orwellian? Jakobiner, später Faschisten missbrauchten.
|
||||
|
||||
### Paranoia
|
||||
Letzte Jahre: Psychisch krank. Ungerechtfertigte Anschuldigungen (Hume). Menschenscheu. Beeinträchtigt Werk?
|
||||
|
||||
### Genf vs. Schriften
|
||||
Genfer Bürger, Lob der kleinen Republik. Aber: Gibt Bürgerrecht auf, lebt in Frankreich. Heuchlerisch?
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Jean-Jacques Rousseau bleibt einer der einflussreichsten und widersprüchlichsten Denker:
|
||||
|
||||
- **Demokratietheorie** - Volkssouveränität fundamental
|
||||
- **Pädagogik** - Revolution der Erziehung
|
||||
- **Romantik** - Gefühl über Vernunft
|
||||
- **Kulturkritik** - Entfremdungsanalyse modern
|
||||
|
||||
Seine Größe: Radikale Kritik der Zivilisation, Vision natürlicher Freiheit, Begründung der Volkssouveränität. Er dachte gegen seine Zeit.
|
||||
|
||||
Seine Problematik: Widersprüche zwischen Leben und Lehre, Totalitarismus-Potential seiner Ideen, Paranoia, Frauenbild.
|
||||
|
||||
Rousseau war Prophet und Neurotiker, Visionär und Egoist, Wegbereiter der Demokratie und potentieller Totalitärer. Diese Ambivalenz macht ihn endlos faszinierend - und gefährlich aktuell.
|
||||
|
||||
Seine zentrale Einsicht bleibt: Moderne Gesellschaft entfremdet. Freiheit erfordert aktive Bürgerschaft. Erziehung muss das Kind respektieren. Diese Ideen leben - trotz aller Widersprüche ihres Urhebers.`,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('📝 Batch 3: Bruce Lee, Charlie Chaplin, Ben-Gurion, Rousseau\n');
|
||||
|
||||
const authorsDE = loadAuthors('de');
|
||||
const authorsEN = loadAuthors('en');
|
||||
|
||||
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 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`);
|
||||
|
||||
writeAuthors(updatedDE, 'de');
|
||||
writeAuthors(updatedEN, 'en');
|
||||
|
||||
console.log('\n✨ Batch 3 fertig!\n');
|
||||
}
|
||||
|
||||
main();
|
||||
897
apps/quote/apps/mobile/scripts/addBiosBatch4.ts
Normal file
897
apps/quote/apps/mobile/scripts/addBiosBatch4.ts
Normal file
|
|
@ -0,0 +1,897 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Batch 4: Johannes Paul II, J.K. Rowling, Alfred Adler, Erich Kästner
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
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[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
|
||||
`;
|
||||
fs.writeFileSync(filePath, tsContent, 'utf-8');
|
||||
console.log(`✅ ${lang}.ts aktualisiert`);
|
||||
}
|
||||
|
||||
const batch4Bios: Record<string, string> = {
|
||||
'johannes-paul-ii': `# Johannes Paul II.
|
||||
*18. Mai 1920 - 2. April 2005*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Karol Józef Wojtyła, bekannt als Papst Johannes Paul II., war der erste nicht-italienische Papst seit 455 Jahren und der erste polnische Papst überhaupt. Sein fast 27-jähriges Pontifikat (1978-2005) war das drittlängste der Geschichte und prägte die katholische Kirche und Weltpolitik nachhaltig. Als charismatischer Reisepapst besuchte er 129 Länder und wurde zur globalen moralischen Autorität. Seine Rolle beim Fall des Kommunismus in Osteuropa machte ihn zur Schlüsselfigur des 20. Jahrhunderts.
|
||||
|
||||
## Kindheit im Schatten des Krieges (1920-1946)
|
||||
|
||||
### Wadowice
|
||||
Geboren in Wadowice, Kleinstadt nahe Krakau (Polen). Vater: Karol Wojtyła Sr., Offizier der österreichisch-ungarischen Armee. Mutter: Emilia Kaczorowska. Katholische Mittelschichtfamilie. Nachbarschaft mit jüdischen Familien - prägte Haltung zu Judentum.
|
||||
|
||||
### Frühe Verluste
|
||||
- Mutter stirbt 1929 (Karol 8 Jahre alt)
|
||||
- Bruder Edmund stirbt 1932 an Scharlach (Karol 12)
|
||||
- Nur Vater und Karol bleiben
|
||||
|
||||
Vater erzieht streng katholisch. Tägliches Gebet. Disziplin. Aber auch Liebe.
|
||||
|
||||
### Jugend und Theater
|
||||
Brillanter Schüler. Sportlich (Fußball, Schwimmen, Kajak). Leidenschaftlicher Theaterbegeisterter. Schauspielerei in Schulaufführungen. Talent. Erwägt Schauspielkarriere.
|
||||
|
||||
### Studium in Krakau (1938)
|
||||
Jagiellonen-Universität. Polnische Philologie. Theatergruppe. Gedichte schreibend. Lebensfroh. Dann: 1. September 1939 - Deutsche Invasion.
|
||||
|
||||
### Nazi-Besatzung (1939-1945)
|
||||
Universität geschlossen. Zwangsarbeit in Steinbruch, dann Chemiefabrik. Untergrund-Theatergruppe "Rhapsodic Theater". Gefahr: Verhaftung, Deportation, Tod. Vater stirbt 1941 - Karol völlig allein.
|
||||
|
||||
### Priesterberufung
|
||||
Tod, Leid, Unterdrückung wecken Berufung. 1942 Eintritt ins geheime Priesterseminar (Nazis hatten Seminar geschlossen). Studium im Untergrund. Kardinal Sapieha schützt.
|
||||
|
||||
### Kriegsende und Weihe (1946)
|
||||
Deutschland kapituliert. Seminar wieder legal. 1. November 1946: Priesterweihe. 26 Jahre alt.
|
||||
|
||||
## Priester, Bischof, Kardinal (1946-1978)
|
||||
|
||||
### Rom und Promotion (1946-1948)
|
||||
Studium am Angelicum in Rom. Doktorarbeit über Johannes vom Kreuz (spanischer Mystiker). Phänomenologie und Thomismus verbunden. Rückkehr nach Polen.
|
||||
|
||||
### Landpfarrer und Studentenseelsorger
|
||||
Zunächst Kaplan in Dörfern. Ab 1949 Studentenseelsorger in Krakau. Beliebt bei Jugend. Ski-Ausflüge, Kajak-Touren, Diskussionen. "Wujek" (Onkel) genannt. Charismatisch.
|
||||
|
||||
### Zweite Doktorarbeit
|
||||
Habilitation über Max Scheler. Phänomenologie und katholische Ethik. Werden akademischer Philosoph und Ethiker. Lehrtätigkeit.
|
||||
|
||||
### Bischof (1958)
|
||||
Mit 38 Jahren Weihbischof von Krakau. Jüngster Bischof Polens. Zweites Vatikanisches Konzil (1962-1965): Aktiver Teilnehmer. Mitarbeit an "Gaudium et Spes" (Pastoralkonstitution). Öffnung zur Moderne.
|
||||
|
||||
### Erzbischof von Krakau (1964)
|
||||
Kardinal (1967). Konfrontation mit kommunistischem Regime. Kampf um Religionsfreiheit. Bau neuer Kirchen. Pastoral unter Diktatur. Geschickt und mutig.
|
||||
|
||||
### "Liebe und Verantwortung" (1960)
|
||||
Buch über Sexualethik. Personalistischer Ansatz. Basis für spätere "Theologie des Leibes". Fortschrittlicher als man denkt: Würde der Frau, eheliche Liebe, gegen Objektivierung.
|
||||
|
||||
## Papst Johannes Paul II. (1978-2005)
|
||||
|
||||
### Konklave 1978 - "Das Jahr der drei Päpste"
|
||||
- Paul VI. stirbt August 1978
|
||||
- Johannes Paul I. gewählt, stirbt nach 33 Tagen
|
||||
- Oktober 1978: Neues Konklave
|
||||
|
||||
16. Oktober: Wojtyła gewählt. Sensation! Erster Nicht-Italiener seit 1523. Erster Pole. Jüngster (58) seit über 100 Jahren.
|
||||
|
||||
### "Habemus Papam"
|
||||
> "Fürchtet euch nicht! Öffnet die Türen für Christus!"
|
||||
|
||||
Erste Worte. Charisma sofort spürbar. Massen begeistert.
|
||||
|
||||
### Der Reisepapst
|
||||
129 Länder besucht. Mehr als alle Päpste zuvor zusammen. Pastoralreisen als Evangelisierung. Weltjugendtage. Millionen Menschen persönlich getroffen.
|
||||
|
||||
**Historische Reisen:**
|
||||
- **Polen** (1979): Erste Reise. Erweckte polnische Freiheitsbewegung. Millionen bei Messen. Solidarność inspiriert.
|
||||
- **Mexiko, Brasilien**: Befreiungstheologie-Kritik, aber Solidarität mit Armen
|
||||
- **USA**: Mehrfach. Kritik und Dialog.
|
||||
- **Kuba** (1998): Treffen mit Castro
|
||||
- **Heiliges Land** (2000): Versöhnung mit Juden, Besuch Westmauer
|
||||
- **Syrien, Griechenland**: Ökumene mit Orthodoxie
|
||||
|
||||
### Attentat (13. Mai 1981)
|
||||
Petersplatz. Mehmet Ali Ağca schießt. Schwer verletzt. Notoperation. Überlebt knapp. Dankt Maria von Fátima (13. Mai = Fátima-Jahrestag).
|
||||
|
||||
1983: Besucht Attentäter im Gefängnis. Vergebung. Ikonisches Bild.
|
||||
|
||||
## Kampf gegen Kommunismus
|
||||
|
||||
### Polen und Solidarność
|
||||
1979-Besuch in Polen: Wendepunkt. Massen erkennen: Regime nicht allmächtig. Solidarność (1980) direkt inspiriert. Johannes Paul unterstützt diskret.
|
||||
|
||||
### Fall der Mauer (1989)
|
||||
Polen: Erste freie Wahlen. Dominoeffekt: Ungarn, Tschechoslowakei, DDR. Papst: Moralische Autorität des Widerstands. Reagan, Thatcher: Politische Verbündete. Dreigespann gegen Moskau.
|
||||
|
||||
### Ende der Sowjetunion (1991)
|
||||
Gorbatschow 1989 im Vatikan. Religionsfreiheit in UdSSR. Papst: Beschleuniger des Endes. Historiker: Ohne Johannes Paul II. kein friedlicher Zusammenbruch.
|
||||
|
||||
### Vermächtnis
|
||||
Befreiung Osteuropas. Demokratie. Keine Gewalt. Christentum als Freiheitskraft. Größte politische Leistung.
|
||||
|
||||
## Theologie und Lehre
|
||||
|
||||
### "Redemptor Hominis" (1979)
|
||||
Erste Enzyklika. Christozentrismus. Mensch findet sich selbst nur in Christus. Würde jedes Menschen. Personalistisch.
|
||||
|
||||
### "Theologie des Leibes" (1979-1984)
|
||||
Katechesen über menschliche Sexualität. Revolutionär für Katholizismus:
|
||||
- Körper gut (gegen Leibfeindlichkeit)
|
||||
- Eheliche Liebe als Abbild göttlicher Liebe
|
||||
- Sexualität heilig
|
||||
- Aber: Humanae Vitae bekräftigt (keine Verhütung)
|
||||
|
||||
### "Evangelium Vitae" (1995)
|
||||
"Kultur des Lebens" gegen "Kultur des Todes". Gegen Abtreibung, Euthanasie, Todesstrafe. Konsistent pro-life. Kritik am Materialismus, Utilitarismus.
|
||||
|
||||
### Sozialenzykliken
|
||||
"Laborem Exercens", "Sollicitudo Rei Socialis", "Centesimus Annus": Kritik an Kapitalismus UND Kommunismus. Würde der Arbeit. Option für Arme. Solidarität. Christliche Sozialethik aktualisiert.
|
||||
|
||||
## Ökumene und Interreligiöser Dialog
|
||||
|
||||
### Mit Juden
|
||||
Revolutionär:
|
||||
- Synagoge in Rom besucht (1986) - Papst erstmals!
|
||||
- Israel-Besuch (2000): Yad Vashem, Klagemauer
|
||||
- "Ältere Brüder im Glauben"
|
||||
- Schoa als moralische Verpflichtung
|
||||
|
||||
### Mit Muslimen
|
||||
- Koran küsst (symbolisch)
|
||||
- Moschee in Damaskus besucht (2001)
|
||||
- Aber: Kritik am radikalen Islam
|
||||
- Dialog trotz Spannungen
|
||||
|
||||
### Mit Orthodoxen
|
||||
- Konstantinopel, Athen besucht
|
||||
- Versöhnungsgesten
|
||||
- Aber: Große Einheit unerreicht
|
||||
|
||||
### Mit Protestanten
|
||||
- Gemeinsame Erklärung zur Rechtfertigungslehre (Lutheraner, 1999)
|
||||
- Canterbury besucht
|
||||
- Aber: Weiheämter weiter Problem
|
||||
|
||||
### Assisi-Treffen (1986, 2002)
|
||||
Weltreligionen zum Friedensgebet. Kontrovers. Konservative: Synkretismus. Johannes Paul: Zeichen des Friedens.
|
||||
|
||||
## Konservative Seite
|
||||
|
||||
### Sexualmoral
|
||||
Keine Zugeständnisse:
|
||||
- Keine Priesterinnenweihe (Ordinatio Sacerdotalis, 1994): "Definitiv"
|
||||
- Zölibat unverrückbar
|
||||
- Homosexualität: Liebe ja, Akte nein
|
||||
- Verhütung weiter verboten
|
||||
- Abtreibung: Absolutes Nein
|
||||
|
||||
Kritik: Realitätsfern, besonders in AIDS-Krise (Kondom-Verbot).
|
||||
|
||||
### Befreiungstheologie
|
||||
Kritisch gegenüber lateinamerikanischer Befreiungstheologie. Marxismus-Nähe abgelehnt. Sanktionen gegen Theologen (Leonardo Boff). Konservative Bischöfe ernannt. Linke: Verrat an Armen.
|
||||
|
||||
### Disziplinierung
|
||||
Theologen gemaßregelt: Hans Küng, Charles Curran. Kirche zentr
|
||||
|
||||
alisiert. Autoritär (trotz Charisma). Lehramt über Ortskirchen.
|
||||
|
||||
### Missbrauchsskandal
|
||||
Unter seiner Herrschaft vertuscht. Maciel (Legionäre Christi): Geschützt trotz Missbrauch. Schwarzes Kapitel. Erst Benedikt XVI. handelt.
|
||||
|
||||
## Letzte Jahre und Tod (2001-2005)
|
||||
|
||||
### Parkinson
|
||||
Ab 1990er sichtbar. Zittern, undeutliche Sprache, Bewegungsprobleme. Öffentlich. Nicht zurückgetreten. "Leiden als Zeugnis".
|
||||
|
||||
### Krankheit als Verkündigung
|
||||
Letzte Jahre: Gebrechlich, leidend, aber weiter im Amt. Botschaft: Würde auch im Leiden. Alter ist wertvoll. Gegen Euthanasie.
|
||||
|
||||
### Tod (2. April 2005)
|
||||
Karsamstag. Lange Agonie. Letzte Worte: "Amen." Millionen Trauernde in Rom. "Santo Subito!" (Sofort heilig!) gerufen.
|
||||
|
||||
### Beerdigung
|
||||
Größte Zusammenkunft von Staatsoberhäuptern der Geschichte. 4 Könige, 5 Königinnen, 70 Präsidenten. 300.000 Menschen. Welt trauert.
|
||||
|
||||
### Seligsprechung (2011)
|
||||
Benedikt XVI. spricht selig. Rekordverdächtig schnell. Johannes Paul-Generation drängt. Wunder: Nonne geheilt.
|
||||
|
||||
### Heiligsprechung (2014)
|
||||
Papst Franziskus heiligt zusammen mit Johannes XXIII. (ohne zweites Wunder). "Heiliger Johannes Paul der Große".
|
||||
|
||||
## Persönlichkeit
|
||||
|
||||
### Charisma
|
||||
Elektrisierende Präsenz. Massen begeistert. Schauspielerisches Talent. Medial genial. Erste "Medienpapst".
|
||||
|
||||
### Sportler und Outdoor-Mensch
|
||||
Bis spät Ski gefahren. Wandern. Schwimmen. Pool im Vatikan installiert. Gesundheit als Wert.
|
||||
|
||||
### Intellektueller
|
||||
Fünf Sprachen fließend. Philosoph. Poet (schrieb Gedichte bis ins Pontifikat). Belesen. Tiefe Theologie.
|
||||
|
||||
### Konservativ und Progressiv
|
||||
Widersprüchlich:
|
||||
- Sozial progressiv (Arme, Arbeiter, Frieden)
|
||||
- Moralisch konservativ (Sex, Gender)
|
||||
- Ökumenisch offen
|
||||
- Disziplinär streng
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Fürchtet euch nicht!"
|
||||
|
||||
> "Öffnet die Türen für Christus!"
|
||||
|
||||
> "Sei, wer du bist, und werde, was du sein sollst."
|
||||
|
||||
> "Die Zukunft beginnt heute, nicht morgen."
|
||||
|
||||
> "Das Leben eines jeden Menschen ist heilig, vom Anfang bis zum Ende."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Johannes Paul II. bleibt eine der prägendsten Figuren des 20. Jahrhunderts:
|
||||
|
||||
- **Politisch**: Fall des Kommunismus
|
||||
- **Kirchlich**: Reisepapst, Weltjugendtage, Globalisierung der Kirche
|
||||
- **Dialogisch**: Ökumene, interreligiöser Dialog
|
||||
- **Moralisch**: Globale Autorität
|
||||
|
||||
**Aber auch:**
|
||||
- Missbrauchsskandal nicht adressiert
|
||||
- Sexualmoral starr
|
||||
- Zentralisierung der Macht
|
||||
- Befreiungstheologie gebremst
|
||||
|
||||
Sein Einfluss ist ambivalent. Für Millionen: Heiliger und Visionär. Für Kritiker: Reaktionär in sozialen Fragen.
|
||||
|
||||
Die Figur Johannes Paul II. zeigt: Auch Heilige sind Menschen. Größe und Versagen können koexistieren. Seine Rolle beim Fall des Kommunismus und seine moralische Autorität bleiben unbestritten. Seine Härte in moralischen Fragen und sein Versagen beim Missbrauchsskandal ebenso.
|
||||
|
||||
Er prägte das Papsttum neu: Reisend, medial, charismatisch. Franziskus ist anders, aber ohne Johannes Paul undenkbar.`,
|
||||
|
||||
'rowling-jk': `# J.K. Rowling
|
||||
*31. Juli 1965 - heute*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Joanne Kathleen Rowling ist eine britische Schriftstellerin, die mit der "Harry Potter"-Reihe das erfolgreichste Literaturphänomen der Gegenwart schuf. Aus ärmlichen Verhältnissen als alleinerziehende Mutter wurde sie zur ersten Milliardärin durch Schreiben. Die sieben Harry-Potter-Bücher (1997-2007) verkauften über 500 Millionen Exemplare und wurden zur prägenden Lese-Erfahrung einer Generation. Als Philanthropin spendet sie Hunderte Millionen. In jüngerer Zeit kontrovers wegen Äußerungen zu Transgender-Themen.
|
||||
|
||||
## Kindheit und Jugend (1965-1990)
|
||||
|
||||
### Yate, England
|
||||
Geboren in Yate bei Bristol als Joanne Rowling. Vater Peter: Rolls-Royce-Ingenieur. Mutter Anne: Wissenschaftsassistentin. Schwester Dianne (Di), zwei Jahre jünger. Normale Mittelschichtfamilie.
|
||||
|
||||
### Frühe Schreiblust
|
||||
Mit 6 Jahren erste Geschichte: "Rabbit". Schrieb ständig. Fantasiereich. Erzählte Schwester Geschichten. Wusste früh: Will Schriftstellerin werden.
|
||||
|
||||
### Wyedean Comprehensive
|
||||
Sekundarschule. Schüchtern, Brillenträgerin (wie Harry). Gute Schülerin, besonders Englisch. Nicht populär. Außenseiter-Erfahrung (später in Hogwarts-Häusern).
|
||||
|
||||
### Mutter erkrankt (1980)
|
||||
Anne Rowling Multiple Sklerose-Diagnose. Lange Krankheit, zunehmende Behinderung. Joanne 15 Jahre alt. Prägt Thema Tod in Harry Potter.
|
||||
|
||||
### Universität Exeter (1983-1987)
|
||||
Studium Französisch und Klassische Philologie. Vater wollte Sekretärin. Joanne: Kompromiss (Sprachen = "nützlich"). Auslandsjahr in Paris. Abschluss: Nichts Besonderes. Unsicher über Zukunft.
|
||||
|
||||
## Die harten Jahre (1990-1997)
|
||||
|
||||
### Portugal (1991-1993)
|
||||
Englischlehrerin in Porto. Partyleben. Treffen mit Jorge Arantes (TV-Journalist). Leidenschaftliche Beziehung. Heirat 1992. Tochter Jessica (1993). Ehe gewalttätig. Trennung und Scheidung. Joanne flieht mit Baby nach Schottland.
|
||||
|
||||
### Edinburgh - Am Boden
|
||||
Alleinerziehende Mutter. Keine Arbeit. Sozialhilfe. Depression. Selbstmordgedanken. Winzige Wohnung. Arm. Gedemütigt. Aber: Baby und Schreiben halten am Leben.
|
||||
|
||||
### Harry Potter-Idee (1990)
|
||||
Zugfahrt Manchester-London: Idee für Harry Potter kommt plötzlich. Junge, weiß nicht, dass er Zauberer ist. Hogwarts. Sofort klar, detailliert. Kein Stift dabei. Züge verspätet. Vier Stunden Gedanken entwickelt.
|
||||
|
||||
### Schreiben in Cafés
|
||||
Nicolson's Café (Edinburgh). Baby Jessica schläft. Joanne schreibt. Stundenweise. Notizblock. Longhand. Kein Computer. Tasse Kaffee. Entwickelt Welt: 7 Bücher geplant von Anfang.
|
||||
|
||||
### Mutters Tod (1990)
|
||||
Anne Rowling stirbt, bevor Harry Potter fertig. Joanne tief getroffen. Thema Tod in Büchern noch zentraler. Lily Potters Opfer = Annes Liebe. Spiegel Nerhegeb (zeigt Tote) = Sehnsucht.
|
||||
|
||||
### Lehrerdiplom (1996)
|
||||
Ausbildung zur Französisch-Lehrerin. Perspektive. Aber: Buch endlich fertig (1995). "Harry Potter und der Stein der Weisen". Will Verlag finden.
|
||||
|
||||
## Der Durchbruch (1995-2000)
|
||||
|
||||
### 12 Ablehnungen
|
||||
Manuskript eingeschickt. Ablehnung nach Ablehnung. "Zu lang für Kinder." "Nicht kommerziell." "Niemand kauft Fantasy." Frustration. Zweifel.
|
||||
|
||||
### Bloomsbury sagt Ja (1996)
|
||||
Christopher Little (Agent) findet Bloomsbury. Kleiner Verlag. Barry Cunningham (Editor): "Alice (8) liebt es!" Vertrag. Vorschuss: £1,500 (lächerlich). Aber: Veröffentlichung!
|
||||
|
||||
### "J.K." statt "Joanne"
|
||||
Verlag rät: Geschlechterneutral. Jungen lesen keine Autorinnen. Joanne fügt "K" (Kathleen, Großmutter) hinzu. "J.K. Rowling" geboren.
|
||||
|
||||
### 26. Juni 1997 - Veröffentlichung
|
||||
"Harry Potter and the Philosopher's Stone". 500 Exemplare. Erste Auflage klein. Heute Sammlerstücke (Tausende £).
|
||||
|
||||
### Mundpropaganda-Erfolg
|
||||
Kinder lieben es. Eltern lesen. Lehrer empfehlen. Mund-zu-Mund. Keine große Werbung. Organisch. Kinder bestehen auf Fortsetzung.
|
||||
|
||||
### Amerikaner interessiert
|
||||
Scholastic (USA) kauft Rechte für $105,000 (Rekord für Kinderbuch). Titel geändert: "Sorcerer's Stone" (Amerikaner kennen Philosophenstein nicht). USA: Noch größerer Markt.
|
||||
|
||||
### Chamb
|
||||
|
||||
er of Secrets (1998)
|
||||
Zweites Buch. Bestseller sofort. Erwartung riesig. Liefert. Dunkler. Basilisk. Tom Riddle. Fangemeinde wächst exponentiell.
|
||||
|
||||
### Prisoner of Azkaban (1999)
|
||||
Drittes Buch. Komplexer. Zeitreise. Sirius Black. Reifer. Kritiker anerkennen: Nicht nur Kinderbuch. Erwachsene lesen offen.
|
||||
|
||||
### Goblet of Fire (2000)
|
||||
Buch 4. 636 Seiten (doppelt so lang). Midnight-Release-Parties. Hysterie. Tri-Wizard-Turnier. Voldemorts Rückkehr. Cedrics Tod (erste Hauptfigur stirbt). Dunkler Ton. Kritischer Punkt: Von hier kein Zurück.
|
||||
|
||||
## Das Phänomen (2000-2007)
|
||||
|
||||
### Medienhype
|
||||
J.K. Rowling überall. Interviews. Titelseiten. Reichste Frau Großbritanniens (überholt Queen). Vergleiche mit Dickens, Dahl. Kulturphänomen.
|
||||
|
||||
### Film-Adaptionen
|
||||
Warner Brothers. Chris Columbus (Regie). Rowling: Kreativkontrolle. Casting-Approval. Britische Schauspieler. Treue zum Buch. Daniel Radcliffe, Emma Watson, Rupert Grint: Weltberühmt.
|
||||
|
||||
### Order of the Phoenix (2003)
|
||||
Buch 5. 870 Seiten. Dunkelstes bisher. Harrys Teenager-Wut. Umbridges Tyrannei. Sirius' Tod. Prophezeiung. Große Erwartung. Liefert.
|
||||
|
||||
### Half-Blood Prince (2005)
|
||||
Buch 6. Voldemorts Vergangenheit. Horkruxe. Snapes Verrat (?). Dumbledores Tod. Schock. Fans trauern.
|
||||
|
||||
### Deathly Hallows (2007)
|
||||
Buch 7. Finale. Camping, Horkrux-Jagd, Schlacht um Hogwarts. Snapes Redemption. Epilog "19 Jahre später". Millionen lesen in 24 Stunden. Kulturelles Ereignis.
|
||||
|
||||
### Zahlen zum Phänomen
|
||||
- 500+ Millionen Bücher verkauft
|
||||
- 80 Sprachen übersetzt
|
||||
- 8 Filme (Deathly Hallows geteilt), über $7 Milliarden eingespielt
|
||||
- Themenparks (Universal Studios)
|
||||
- Merchandise-Imperium
|
||||
|
||||
### Einfluss
|
||||
- Ganze Generation las wieder
|
||||
- "Harry Potter Generation"
|
||||
- Mitternachts-Release-Parties als Kulturphänomen
|
||||
- Fanfiction-Explosion
|
||||
- Kinder-Literatur legitimiert
|
||||
|
||||
## Nach Harry Potter (2007-heute)
|
||||
|
||||
### "The Casual Vacancy" (2012)
|
||||
Erwachsenen-Roman. Kleinstadtintrigen. Sozialkritik. Düster. Kritiken gemischt. Bestseller (Name), aber enttäuscht manche Fans.
|
||||
|
||||
### Robert Galbraith - Cormoran Strike (ab 2013)
|
||||
Pseudonym. Krimis. "The Cuckoo's Calling" (2013) zunächst unbemerkt veröffentlicht. Gute Kritiken, schlechte Verkäufe. Leak: J.K. Rowling. Sofort Bestseller. Serie fortsetzt. Besser aufgenommen als "Vacancy".
|
||||
|
||||
### Fantastic Beasts (ab 2016)
|
||||
Prequel-Filmserie zu Harry Potter. Rowling: Drehbücher. 1920er New York. Newt Scamander. Grindelwald. Fünf Filme geplant. Drei erschienen. Box Office OK, aber nicht Potter-Level. Qualität sinkt.
|
||||
|
||||
### Pottermore / Wizarding World
|
||||
Website. Ergänzende Infos. Häuser-Sortierung. Neue Geschichten. Problematisch: Retcons (Dumbledore schwul, Hermione evtl. schwarz, etc.). Fans gespalten: Bereicherung oder Geldmacherei?
|
||||
|
||||
### Theaterstück "Cursed Child" (2016)
|
||||
Harrys Sohn Albus. Zeitreise. Canon? Umstritten. Kommerziell erfolgreich. Literarisch: Gemischt.
|
||||
|
||||
## Philanthropie
|
||||
|
||||
### Spenden
|
||||
Schätzungsweise £150+ Millionen gespendet. Verliert Milliardärs-Status dadurch (+ Steuern). Egal, will helfen.
|
||||
|
||||
### Gründungen
|
||||
- **Lumos**: Hilft institutionalisierten Kindern (Waisen), zurück in Familien
|
||||
- **Volant Charitable Trust**: Multiple Sklerose-Forschung, Armutsbekämpfung
|
||||
|
||||
### Single Parents, Multiple Sclerosis
|
||||
Unterstützt Alleinerziehende (eigene Erfahrung). MS-Forschung (Mutter). Persönliche Themen.
|
||||
|
||||
## Die Transgender-Kontroverse (ab 2019)
|
||||
|
||||
### Anfänge
|
||||
2019: Tweet zu Maya Forstater-Fall (Entlassung wegen Gender-Aussagen). Rowling: Solidarität. Kritik: Transfeindlich.
|
||||
|
||||
### Juni 2020 - Essay
|
||||
Langes Statement zu Gender. Punkte:
|
||||
- Biologisches Geschlecht real
|
||||
- Frauen-Räume schützen
|
||||
- Detransitionen erwähnen
|
||||
- Sorge um Kinder und Transitionierung
|
||||
|
||||
### Reaktion
|
||||
Massiver Backlash:
|
||||
- "TERF" (Trans-Exclusionary Radical Feminist) genannt
|
||||
- Boykott-Aufrufe
|
||||
- Daniel Radcliffe, Emma Watson, Rupert Grint distanzieren sich
|
||||
- Transgender-Aktivisten: "Schädlich, gefährlich"
|
||||
|
||||
### Rowlings Position
|
||||
Behauptet: Nicht transfeindlich. Unterstützt Transrechte. Aber: Biologisches Geschlecht wichtig. Frauen-Räume schützen. Free Speech verteidigen.
|
||||
|
||||
### Konsequenzen
|
||||
- Kulturkrieg-Ikone geworden (ungewollt?)
|
||||
- Politisch gespalten: Konservative applaudieren, Progressive attackieren
|
||||
- Vermächtnis kompliziert
|
||||
- "Harry Potter" selbst bleibt geliebt (meist)
|
||||
|
||||
## Persönlichkeit
|
||||
|
||||
### Schüchtern vs. Öffentlich
|
||||
Introvertiert. Interviews schwierig. Aber: Verteidigt sich scharf auf Twitter. Widerspruch.
|
||||
|
||||
### Detailversessen
|
||||
Plante sieben Bücher von Anfang. Notizen, Timelines, Genealogien. Kontrollbedürfnis (kreativ).
|
||||
|
||||
### Selbstkritisch
|
||||
Spricht offen über Depression, Suizidgedanken, Versagen. Keine Schönfärbung.
|
||||
|
||||
### Politisch
|
||||
Labour-Wählerin. Soziale Gerechtigkeit. Aber: Transgender-Debatte macht politisch obdachlos.
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Es ist unsere Entscheidungen, die zeigen, wer wir wirklich sind, weit mehr als unsere Fähigkeiten."
|
||||
|
||||
> "Glück kann man auch in den dunkelsten Zeiten finden, wenn man sich nur daran erinnert, das Licht anzumachen."
|
||||
|
||||
> "Worte sind unsere unerschöpflichste Quelle der Magie."
|
||||
|
||||
> "Es braucht viel Mut, sich seinen Feinden zu stellen, aber genauso viel, sich seinen Freunden entgegenzustellen."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
J.K. Rowling bleibt die prägendste Kinderbuchautorin des 21. Jahrhunderts:
|
||||
|
||||
- **Harry Potter** - kulturelles Phänomen einer Generation
|
||||
- **Vom Tellerwäscher zum Millionär** - moderne Märchengeschichte
|
||||
- **Lesemotivation** - machte Lesen cool
|
||||
- **Philanthropin** - gibt zurück
|
||||
|
||||
**Aber:**
|
||||
- **Transgender-Kontroverse** - spaltet Fangemeinde
|
||||
- **Post-Potter-Werke** - enttäuschen teils
|
||||
- **Retcons** - Geldmacherei-Vorwurf
|
||||
|
||||
Ihre Geschichte ist shakespearean: Armut, Erfolg, Kontroverse. Ihr Werk wird bleiben. Ihre Person bleibt umstritten. Harry Potter selbst - größer als die Autorin, Botschaft von Toleranz, Freundschaft, Mut - überlebt die Kontroversen.
|
||||
|
||||
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
|
||||
*7. Februar 1870 - 28. Mai 1937*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Alfred Adler war ein österreichischer Arzt und Psychotherapeut, der als Begründer der Individualpsychologie die Tiefenpsychologie nachhaltig prägte. Zunächst Mitarbeiter Sigmund Freuds, brach er 1911 mit diesem und entwickelte eine eigene Richtung, die den Menschen als soziales Wesen und das Streben nach Überlegenheit (nicht Sexualität) als zentral ansah. Konzepte wie Minderwertigkeitskomplex, Kompensation, Lebensstil und Gemeinschaftsgefühl stammen von ihm. Als sozialer Reformer verband er Psychologie mit Pädagogik und Sozialarbeit.
|
||||
|
||||
## Kindheit in Wien (1870-1888)
|
||||
|
||||
### Rudolfsheim, Vorstadt Wien
|
||||
Geboren als zweites von sieben Kindern. Vater Leopold Adler: jüdischer Getreidehändler. Mutter Pauline. Mittelständische, assimilierte jüdische Familie. Nicht religiös.
|
||||
|
||||
### Rachitis und Krankheit
|
||||
Frühkindliche Rachitis (Vitamin-D-Mangel). Schwächlich. Oft krank. Neid auf gesunden älteren Bruder Sigmund. Unfälle: Mit 3 Jahren fast überfahren. Mit 5 Jahren Lungenentzündung, fast gestorben. Arzt: "Nicht zu retten." Überlebte. Entschied: Werde Arzt.
|
||||
|
||||
**Prägend:** Erfahrung von Schwäche, Minderwertigkeit, Krankheit. Später Theorie: Organminderwertigkeit als Kompensationsantrieb.
|
||||
|
||||
### Schulzeit
|
||||
Anfangs schlechter Schüler (Mathematik). Lehrer: "Besser Schuster werden." Vater glaubt an ihn. Alfred arbeitet hart. Wird Klassenbester. **Kompensation in Aktion.**
|
||||
|
||||
### Brüderliche Rivalität
|
||||
Bruder Sigmund stirbt früh. Alfred: Schuldgefühle? Überlebensschuld? Später Theorie: Geschwisterkonstellation prägt Persönlichkeit. Zweitgeborener kompensiert.
|
||||
|
||||
## Medizinstudium und frühe Karriere (1888-1902)
|
||||
|
||||
### Universität Wien
|
||||
Studiert Medizin. Interessiert an allem: Soziale Medizin, Ophthalmologie (Augen), später Neurologie und Psychiatrie. Politisch aktiv: Sozialistisch orientiert. Volksbildung wichtig.
|
||||
|
||||
### Promotion (1895)
|
||||
Dr. med. Zunächst Allgemeinmediziner. Praxis in ärmlichem Prater-Viertel. Zirkusartisten, Akrobaten als Patienten. Beobachtung: Körperliche Defizite führen zu Höchstleistungen. Kompensation!
|
||||
|
||||
### Heirat (1897)
|
||||
Raissa Timofejewna Epstein. Russische Intellektuelle, Sozialistin, Feministin. Vier Kinder. Gleichberechtigte Ehe (für die Zeit radikal). Raissa: Lebenslange Unterstützerin.
|
||||
|
||||
### "Gesundheitsbuch für das Schneidergewerbe" (1898)
|
||||
Erste Publikation. Arbeitsmedizin. Soziale Ursachen von Krankheit. Schneider: Schlechte Arbeitsbedingungen = Krankheit. Prävention durch soziale Reform. **Früher Sozialreformer.**
|
||||
|
||||
### Wechsel zur Psychiatrie
|
||||
Um 1900: Von Allgemeinmedizin zu Neurologie und Psychiatrie. Liest Freud. Fasziniert. Verteidigt "Traumdeutung" öffentlich (1902). Freud bemerkt. Einladung zur "Mittwochsgesellschaft" (später: Wiener Psychoanalytische Gesellschaft).
|
||||
|
||||
## Freud-Jahre (1902-1911)
|
||||
|
||||
### Mittwochsgesellschaft
|
||||
Freud, Adler, Stekel, später Jung. Diskussionen. Adler: Kritisch, eigenständig. Nicht Schüler, sondern Kollege (aus Adlers Sicht).
|
||||
|
||||
### "Studie über Minderwertigkeit von Organen" (1907)
|
||||
Hauptwerk der Frühphase. These: Organ-Minderwertigkeit (schwache Lunge, schlechtes Sehen) treibt zu Kompensation. Überkompensation möglich. Demosthenes (Stotterer wurde Redner) als Beispiel.
|
||||
|
||||
**Konflikt mit Freud:** Adler sieht biologische Basis, nicht Sexualität als primär. Freud misstrauisch.
|
||||
|
||||
### Aggression und Macht
|
||||
Adler: Aggressionstrieb wichtiger als Libido. Wille zur Macht (Nietzsche-Einfluss). Maskuliner Protest (gegen Weiblichkeit).
|
||||
|
||||
Freud: Ablehnung. Nur Libido ist Trieb.
|
||||
|
||||
### Präsident der Wiener Gesellschaft (1910)
|
||||
Adler wird Präsident (Freud bleibt Ehrenpräsident). Spannung wächst. Adler: Eigenständige Theorie. Freud: Häresie.
|
||||
|
||||
### Der Bruch (1911)
|
||||
Offener Konflikt. Adler kritisiert Pansexualismus. Freud: Adler versteht Psychoanalyse nicht. Ultimatum. Adler tritt zurück als Präsident und aus Gesellschaft aus. Neun weitere folgen. Endgültige Trennung.
|
||||
|
||||
**Freud (bitter):** "Der kleine Adler." Neid auf Größe?
|
||||
|
||||
**Adler:** Befreiung. Eigene Richtung.
|
||||
|
||||
## Individualpsychologie (1911-1937)
|
||||
|
||||
### Gründung
|
||||
1911: "Verein für freie psychoanalytische Forschung" (später: Individualpsychologie). Name: Individuum als unteilbare Ganzheit (nicht Individuell vs. Gesellschaft).
|
||||
|
||||
### Kernbegriffe
|
||||
|
||||
**Minderwertigkeitsgefühl:**
|
||||
- Jeder Mensch fühlt sich minderwertig (Kind vs. Erwachsene, Schwäche vs. Ideal)
|
||||
- Treibt zur Kompensation
|
||||
- Gesund: Echte Leistung
|
||||
- Pathologisch: Minderwertigkeitskomplex (gelähmt) oder Überlegenheitskomplex (überkompensiert, aber hohl)
|
||||
|
||||
**Streben nach Überlegenheit / Geltung:**
|
||||
- Nicht Sex, sondern Macht/Anerkennung zentral
|
||||
- Von unten nach oben
|
||||
- "Aufwärtsbewegung"
|
||||
|
||||
**Lebensstil:**
|
||||
- Früh (4-5 Jahre) geformt
|
||||
- Einheitlicher Plan des Lebens
|
||||
- Roter Faden der Persönlichkeit
|
||||
- Therapie: Verstehen und ändern des Lebensstils
|
||||
|
||||
**Gemeinschaftsgefühl (Sozialinteresse):**
|
||||
- Wichtigster Begriff! (später Fokus)
|
||||
- Psychische Gesundheit = starkes Gemeinschaftsgefühl
|
||||
- Neurose = schwaches Gemeinschaftsgefühl
|
||||
- Erziehung muss Gemeinschaftsgefühl fördern
|
||||
|
||||
**Geschwisterkonstellation:**
|
||||
- Geburtsreihenfolge prägt
|
||||
- Erstgeborener: Entthront, konservativ, verantwortlich
|
||||
- Zweitgeborener: Wetteifer, ehrgeizig
|
||||
- Nesthäkchen: verwöhnt oder motiviert
|
||||
- Einzelkind: Zentrum, evtl. egozentrisch
|
||||
|
||||
**Fiktionale Finalität:**
|
||||
- Mensch lebt nach fiktiven Zielen ("Als-ob")
|
||||
- "So, als wäre ich großartig..."
|
||||
- Ziel leitet Verhalten, auch wenn unrealistisch
|
||||
|
||||
### Therapie
|
||||
Kürzer als Freudsche Psychoanalyse. Face-to-face (nicht Couch). Ermutigung zentral. Lebensstil analysieren. Gemeinschaftsgefühl stärken. Praktischer, weniger mystisch als Freud.
|
||||
|
||||
## Erziehung und Sozialreform (1920er)
|
||||
|
||||
### Wiener Erziehungsberatungsstellen (1920er)
|
||||
Adler revolutionär: Kostenlose Erziehungsberatung in Wiener Schulen. Über 30 Stellen. Eltern, Lehrer, Kinder beraten. Präventiv. Gemeinschaftsgefühl fördern.
|
||||
|
||||
**Prinzipien:**
|
||||
- Ermutigung statt Strafe
|
||||
- Natürliche Folgen statt Autorität
|
||||
- Gleichwertigkeit Kind-Erwachsener (nicht gleich, aber gleichwertig)
|
||||
- Demokratische Erziehung
|
||||
|
||||
Beeinflusst: Rudolf Dreikurs, Thomas Gordon, moderne Pädagogik.
|
||||
|
||||
### Volksbildung
|
||||
Vorträge für Laien. Psychologie für alle. Nicht Elfenbeinturm. Verständliche Sprache. Soziale Mission.
|
||||
|
||||
### Sozialismus
|
||||
Zeit seines Lebens sozialistisch. Psychologie muss soziale Verhältnisse ändern. Kritik am Kapitalismus als Konkurrenzgesellschaft, schädigt Gemeinschaftsgefühl. Rot Wien (1919-1934): Adler aktiv beteiligt.
|
||||
|
||||
## Emigration und Tod (1926-1937)
|
||||
|
||||
### USA-Reisen (ab 1926)
|
||||
Vortragsreisen. Enthusiastisch aufgenommen. Praktischer als Freud, optimistischer als Jung. Amerikaner lieben Adler.
|
||||
|
||||
### Columbia University (1932)
|
||||
Gastprofessor, später visiting professor. Long Island College of Medicine: Position. Pendelt zwischen Wien und New York.
|
||||
|
||||
### Austrofaschismus und Nazis
|
||||
1934: Dollfuß-Diktatur in Österreich. Adlers Kliniken geschlossen (sozialistische Verbindungen). 1934: Endgültige Emigration nach USA.
|
||||
|
||||
Antisemitismus wächst. Obwohl konvertiert (Protestant geworden, pragmatisch), Gefahr wegen jüdischer Herkunft.
|
||||
|
||||
### Vortragsreise in Schottland (1937)
|
||||
Mai 1937: Vorträge in Schottland. Herzinfarkt auf Straße in Aberdeen. Sofort tot. 67 Jahre alt. Beerdigung in Edinburgh.
|
||||
|
||||
### Vermächtnis gespalten
|
||||
Zu Lebzeiten: Berühmter als Freud in USA. Nach Tod: Freud dominiert. Adler: Lange unterschätzt. Renaissance ab 1960ern.
|
||||
|
||||
## Vergleich mit Freud und Jung
|
||||
|
||||
### Freud
|
||||
- **Sex vs. Macht**: Libido vs. Streben nach Überlegenheit
|
||||
- **Trieb vs. Ziel**: Vergangenheit (Triebe) vs. Zukunft (Ziele)
|
||||
- **Pessimismus vs. Optimismus**: Determinist vs. Möglichkeit zur Änderung
|
||||
- **Individuum vs. Gesellschaft**: Intrapsychisch vs. interpersonal
|
||||
|
||||
### Jung
|
||||
- **Mystisch vs. Pragmatisch**: Archetypen vs. Soziale Realität
|
||||
- **Spirituell vs. Materialistisch**: Kollektives Unbewusstes vs. Soziale Bedingungen
|
||||
- Beide: Bruch mit Freud. Aber ganz unterschiedliche Richtungen.
|
||||
|
||||
## Einfluss und Wirkung
|
||||
|
||||
### Psychotherapie
|
||||
- **Kognitive Therapie**: Ellis, Beck (Gedanken ändern Gefühle) = adlerianisch
|
||||
- **Humanistische Psychologie**: Maslow, Rogers (Selbstverwirklichung, Optimismus)
|
||||
- **Systemische Therapie**: Familie als System
|
||||
|
||||
### Pädagogik
|
||||
- **Dreikurs**: "Children: The Challenge"
|
||||
- **Positive Discipline**: Jane Nelsen
|
||||
- **Gordon**: "Familienkonferenz"
|
||||
- Demokratische, ermutigende Erziehung
|
||||
|
||||
### Selbsthilfe
|
||||
- **"Think positive"**: Adlerianische Ermutigung
|
||||
- **Growth Mindset** (Carol Dweck): Adlers Optimismus
|
||||
- **Resilienz**: Kompensation von Minderwertigkeitsgefühlen
|
||||
|
||||
### Sozialarbeit
|
||||
Gemeinschaftsgefühl als Ziel. Soziale Faktoren zentral. Prävention wichtig.
|
||||
|
||||
## Berühmte Konzepte
|
||||
|
||||
> "Das einzige, was wir in diesem Leben wirklich besitzen, sind die Beziehungen zu unseren Mitmenschen."
|
||||
|
||||
> "Ein Mensch zu sein bedeutet, ein Gefühl der Minderwertigkeit zu haben, das einen ständig zur Überlegenheit treibt."
|
||||
|
||||
> "Es ist leicht, Kinder zu kritisieren, aber schwer, sie zu ermutigen."
|
||||
|
||||
> "Das wichtigste im Leben ist, zu lernen, zu geben und zu teilen."
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Alfred Adler bleibt der unterschätzte dritte Gigant der Tiefenpsychologie:
|
||||
|
||||
- **Soziale Perspektive**: Mensch als soziales Wesen
|
||||
- **Optimismus**: Änderung möglich
|
||||
- **Pädagogik**: Ermutigende Erziehung
|
||||
- **Gemeinschaftsgefühl**: Psychische Gesundheit = soziales Interesse
|
||||
|
||||
Sein Schatten: Freud überstrahlt. Jung faszinierender. Adler: Zu praktisch? Zu optimistisch? Zu einfach?
|
||||
|
||||
Aber: Sein Einfluss ist riesig - oft unerkannt. Kognitive Therapie, humanistische Psychologie, moderne Pädagogik: Alle adlerianisch beeinflusst. "Minderwertigkeitskomplex" ist Alltagswort.
|
||||
|
||||
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
|
||||
*23. Februar 1899 - 29. Juli 1974*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Erich Kästner war ein deutscher Schriftsteller, Drehbuchautor und Kabarettist, berühmt für seine Kinderbücher wie "Emil und die Detektive" und "Das doppelte Lottchen". Als scharfzüngiger Satiriker und Moralist der Weimarer Republik kritisierte er Militarismus und Nationalsozialismus, überlebte aber das Dritte Reich in Deutschland. Seine klare, sachliche Prosa und sein humanistischer Optimismus prägten Generationen. Trotz Publikations- und Schreibverbot blieb er in Deutschland - ein innerer Emigrant, der nach 1945 zur moralischen Instanz wurde.
|
||||
|
||||
## Kindheit in Dresden (1899-1919)
|
||||
|
||||
### Neustadt, Dresden
|
||||
Geboren in Dresden als einziger Sohn von Emil Kästner (Sattlermeister) und Ida Kästner (geb. Augustin). Mutter: Friseurin, stark, dominant. Vater: zurückhaltend, sanft. Kästner zeit seines Lebens Muttersohn.
|
||||
|
||||
### Intime Mutter-Sohn-Bindung
|
||||
Ida verehrte Erich, nannte ihn "mein Junge". Extrem enge Bindung. Korrespondierten täglich bis zu ihrem Tod (1951). Heute: Grenzüberschreitend? Damals: Intensiv, aber nicht unüblich. Prägte Frauenbild.
|
||||
|
||||
### Vater-Frage
|
||||
Gerücht (später bestätigt): Biologischer Vater war Dr. Emil Zimmermann, Ida's Arbeitgeber (jüdischer Arzt). Erich wusste es wohl. Heimlichkeit. Erklärte Väter Verhältnis? Nichtehelicher Sohn, aber in Ehe großgezogen.
|
||||
|
||||
### Schulzeit
|
||||
König-Georg-Gymnasium, Dresden. Guter Schüler, besonders Deutsch. Schrieb früh Gedichte. Klassenbester. Aber: Leidenschaftslos. Pflichterfüllung.
|
||||
|
||||
### Erster Weltkrieg - Lehrer
|
||||
Lehrerausbildung (Notausbildung wegen Krieg). Mit 18 Jahren Volksschullehrer. Kurze Zeit. Einberufung folgte.
|
||||
|
||||
### Militärdienst (1917-1918)
|
||||
Schwere Artillerie. Ausbildung brutal. Herzkrankheit (dauerhaft) durch Drill. Schikane durch Vorgesetzte. Hass auf Militarismus entsteht. **Prägend für Pazifismus.**
|
||||
|
||||
Nie Fronteinsatz (Kriegsende). Aber: Trauma des Drills bleibt. "Fabian" später: Antimilitarismus.
|
||||
|
||||
## Studium und Weimarer Republik (1919-1933)
|
||||
|
||||
### Leipzig, Rostock, Berlin
|
||||
Studium: Germanistik, Geschichte, Philosophie, Theaterwissenschaft. Promotion 1925 (Leipzig) über Friedrich den Großen und Literatur. Dr. phil.
|
||||
|
||||
### Freier Schriftsteller
|
||||
Entscheidet gegen Akademikerkarriere. Will Schriftsteller werden. Gedichte. Feuilletons. Kabarett-Texte. Freier Journalist. Prekär, aber künstlerisch.
|
||||
|
||||
### "Die Weltbühne" und "Neue Leipziger Zeitung"
|
||||
Publiziert in linksintellektuellen Zeitschriften. Satirische Gedichte. Sozialkritik. Antimilitarismus. Scharfe Zunge. "Herz auf Taille" (1928): Gedichtband. Erfolg.
|
||||
|
||||
### Berlin (ab 1927)
|
||||
Umzug nach Berlin. Zentrum der Weimarer Kultur. Kabarett "Die Katakombe". Kontakte: Kurt Tucholsky, Carl von Ossietzky. Linksliberale Intellektuelle. Gegen Nazis, gegen Militarismus, gegen Spießer.
|
||||
|
||||
### "Fabian" (1931)
|
||||
Großstadtroman. Jakob Fabian: Anti-Held, Moralist, Pessimist. Sittengemälde der untergehenden Weimarer Republik. Erotik, Zynismus, Kapitalismuskritik. Skandal wegen Sex-Szenen. Kritik: Zu moralisierend. Heute: Zeitdokument.
|
||||
|
||||
### "Emil und die Detektive" (1929)
|
||||
Durchbruch als Kinderbuchautor. Emil Tischbein verfolgt Dieb durch Berlin. Kinderbande. Realistische Großstadt. Detektiv-Geschichte. Sofort Klassiker. Verfilmt (1931). Welterfolg. Übersetzungen in 59 Sprachen.
|
||||
|
||||
**Besonderheit:** Respektiert Kinder. Nicht verniedlicht. Intelligente, aktive Kinder. Moderne Pädagogik.
|
||||
|
||||
## NS-Zeit - Innere Emigration (1933-1945)
|
||||
|
||||
### Bücherverbrennung (10. Mai 1933)
|
||||
Kästner erlebt eigene Bücherverbrennung. Opernplatz Berlin. Steht in Menge. Sieht Bücher brennen. Gestapo erkennt ihn (angeblich). Geht nicht weg. **Zeuge der eigenen Verdammung.**
|
||||
|
||||
### Publikationsverbot
|
||||
Bücher verboten. Nicht verhaftet (warum? Unklar. Evtl. Devisenbringer durch internationale Kinderbucher?). Darf nicht publizieren unter eigenem Namen.
|
||||
|
||||
### Warum blieb er?
|
||||
Große Frage. Tucholsky, Ossietzky, Mann: Exil oder Tod. Kästner: Blieb.
|
||||
|
||||
**Seine Begründung:** "Ich bin ein Deutscher aus Dresden in Sachsen. Mich läßt meine Mutter nicht fort." Auch: Chronist bleiben. Zeuge sein. Aber: Kritik an Feigheit, Opportunismus.
|
||||
|
||||
### Pseudonyme und Drehbücher
|
||||
Schrieb Drehbücher unter Pseudonymen. "Münchhausen" (1943, Hans Albers): Erfolgreichster deutscher Film der NS-Zeit. Kästner: Berthold Bürger (Pseudonym). Geld verdient im Dritten Reich. **Kompromiss oder Verrat?**
|
||||
|
||||
### Kriegsende in Tirol
|
||||
Mai 1945: Mit Filmteam in Mayrhofen (Tirol). Kriegsende. Befreiung. Notizen für "Das doppelte Lottchen" (Idee entsteht).
|
||||
|
||||
## Nachkriegszeit (1945-1974)
|
||||
|
||||
### München
|
||||
Lebt in München (nicht Ostberlin). Feuilleton-Chef "Neue Zeitung" (amerikanische Lizenz). Kabarett "Die Schaubude" (später "Kleine Freiheit") - Mitbegründer. Mahner und Kritiker.
|
||||
|
||||
### Kinderbücher der Nachkriegszeit
|
||||
- **"Das doppelte Lottchen"** (1949): Getrennte Zwillinge. Scheidung. Wiederverei
|
||||
|
||||
nigung. Warmherzig. Klassiker.
|
||||
- **"Die Konferenz der Tiere"** (1949): Tiere zwingen Menschen zum Frieden. Pazifistisch. Idealistische Utopie.
|
||||
- **"Das fliegende Klassenzimmer"** (1933, Neuauflage): Internatsjungs. Freundschaft. Lehrer-Schüler-Vertrauen.
|
||||
|
||||
### "Als ich ein kleiner Junge war" (1957)
|
||||
Autobiographie der Kindheit. Dresden um 1900. Mutter-Beziehung. Wehmütig. Nostalgisch. Ehrlich.
|
||||
|
||||
### Büchner-Preis (1957)
|
||||
Höchste deutsche Literatur-Auszeichnung. Anerkennung. Aber: Kritik, dass Kinderbuchautor ausgezeichnet wurde. Kästner: "Auch Kinder sind Menschen."
|
||||
|
||||
### Kritik am Wirtschaftswunder
|
||||
1950er/60er: Deutschland vergisst Vergangenheit. Materialismus. Kästner kritisiert: Verdrängung, Wohlstandsbesessenheit, fehlende Aufarbeitung. Aber: Gehör findet er kaum. Zeitgeist gegen ihn.
|
||||
|
||||
### Impotenz und Alkohol
|
||||
Persönlich: Vereinsamt. Mehrere Affären, keine dauerhafte Beziehung. Spät (1957) Sohn Thomas (mit Friedel Siebert, nicht geheiratet). Trinkt zunehmend. Depression?
|
||||
|
||||
### Letzte Jahre
|
||||
Gesundheitlich angeschlagen. Schreibt weniger. Wiederholungen. Ruhm lebt von Vergangenheit. 1960er/70er: Neue Generation findet Kästner altmodisch.
|
||||
|
||||
### Tod (29. Juli 1974)
|
||||
München. Speiseröhrenkrebs (Rauchen). 75 Jahre alt. Beerdigung: Viele kommen. Deutschland trauert. Kinderautor, Moralist, Zeitzeuge.
|
||||
|
||||
## Literarischer Stil
|
||||
|
||||
### Neue Sachlichkeit
|
||||
Klare, einfache Sprache. Keine Metaphern-Überfülle. Sachlich, aber nicht emotionslos. Anti-Expressionismus. Reportage-Stil. **Verständlich für Kinder und Erwachsene.**
|
||||
|
||||
### Moralischer Ton
|
||||
Immer Moral. Gut vs. Böse klar. Optimismus trotz Kritik. Glaubt an Vernunft, Anstand, Güte. Lehrer-Ton? Ja. Aber auch: Herzenswärme.
|
||||
|
||||
### Humor und Satire
|
||||
Erwachsenenbücher: Scharf satirisch. Kinderb ücher: Warmherziger Humor. Nie zynisch gegenüber Kindern.
|
||||
|
||||
### Pessimismus des Verstandes, Optimismus des Herzens
|
||||
Weiß: Welt schlecht. Menschen schwach. Aber: Kämpft weiter. Hoffnung auf Vernunft. Trotz allem.
|
||||
|
||||
## Politische Haltung
|
||||
|
||||
### Pazifismus
|
||||
Absolut. Militarismus = Wurzel des Übels. "Kennst du das Land, wo die Kanonen blühn?" (Gedicht). Antimilitaristisch.
|
||||
|
||||
### Antifaschismus
|
||||
Gegen Nazis von Anfang. Bücher verbrannt. Aber: Blieb in Deutschland. Innere Emigration = mutig oder feige? Debatte bis heute.
|
||||
|
||||
### Humanismus
|
||||
Glaube an Güte, Vernunft, Anstand. Aufklärung. Bildung. Einfache menschliche Tugenden. Nicht ideologisch, sondern menschlich.
|
||||
|
||||
### Unpolitisch?
|
||||
Kritik: Zu vage. Keine klare politische Theorie. Moralisiert statt analysiert. Verteidigung: Moral IST Politik.
|
||||
|
||||
## Verhältnis zu Frauen und Mutter
|
||||
|
||||
### Mutter Ida
|
||||
Zentral. Tägliche Briefe. "Mein Junge." Über-Mutter? Heute: Problematisch. Kästner selbst reflektierte nicht kritisch.
|
||||
|
||||
### Frauen
|
||||
Mehrere Beziehungen. Keine lange Ehe. Oft jüngere Frauen. Ilse Julius, Luiselotte Enderle, Friedel Siebert (Mutter seines Sohns). Keine erfüllte Partnerschaft? Oder Beziehungsunfähigkeit?
|
||||
|
||||
### Frauenbild
|
||||
Fortschrittlich für Zeit (Frauen als stark, intelligent). Aber: Mutterfixiert. Idealisierung.
|
||||
|
||||
## Berühmte Zitate und Gedichte
|
||||
|
||||
> "Es gibt nichts Gutes, außer: Man tut es."
|
||||
|
||||
> "Wer noch nie einen Fehler gemacht hat, hat sich noch nie um etwas bemüht."
|
||||
|
||||
> "Das Gewissen ist fähig, Unrecht für Recht zu halten. Inquisition für Gott. Und Mord für Politik."
|
||||
|
||||
**"Sachliche Romanze" (Gedicht):**
|
||||
> "Als sie einander acht Jahre kannten / (und man darf sagen: sie kannten sich gut), / kam ihre Liebe plötzlich abhanden. / Wie andern Leuten ein Stock oder Hut."
|
||||
|
||||
Melancholisch. Neue Sachlichkeit in Lyrik.
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
Erich Kästner bleibt der moralische Kinderbuchautor und Satiriker Deutschlands:
|
||||
|
||||
- **Kinderbücher**: "Emil", "Lottchen", "Fliegendes Klassenzimmer" - Generationen geprägt
|
||||
- **Neue Sachlichkeit**: Klare, verständliche Literatur
|
||||
- **Moralist**: Pazifismus, Humanismus, Anstand
|
||||
- **Innerer Emigrant**: Überlebte NS-Zeit in Deutschland - kontrovers
|
||||
|
||||
**Kritik:**
|
||||
- Zu moralisierend?
|
||||
- NS-Zeit: Mut oder Feigheit?
|
||||
- Nachkriegszeit: Zu pessimistisch, zu rückwärtsgewandt?
|
||||
|
||||
Seine Größe: Respekt vor Kindern, klare Sprache, moralischer Kompass. Seine Ambivalenz: NS-Zeit, Alkohol, Vereinsamung.
|
||||
|
||||
Kästners Werke leben. "Emil" wird gelesen. "Doppelte Lottchen" verfilmt. Seine Gedichte zitiert. Sein moralischer Appell: Tut das Gute. Bleibt anständig. Zeitlos - in bösen Zeiten besonders wichtig.`,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('📝 Batch 4: Johannes Paul II, Rowling, Adler, Kästner\n');
|
||||
|
||||
const authorsDE = loadAuthors('de');
|
||||
const authorsEN = loadAuthors('en');
|
||||
|
||||
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 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`);
|
||||
|
||||
writeAuthors(updatedDE, 'de');
|
||||
writeAuthors(updatedEN, 'en');
|
||||
|
||||
console.log('\n✨ Batch 4 fertig!\n');
|
||||
}
|
||||
|
||||
main();
|
||||
475
apps/quote/apps/mobile/scripts/addDetailedBios.ts
Normal file
475
apps/quote/apps/mobile/scripts/addDetailedBios.ts
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Fügt ausführliche Biografien für fehlende Autoren hinzu
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
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[];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export const authors${lang.toUpperCase()}: Author[] = ${authorsJson};
|
||||
`;
|
||||
|
||||
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
|
||||
*13. November 354 - 28. August 430*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
Aurelius Augustinus, bekannt als Augustinus von Hippo, war einer der bedeutendsten christlichen Kirchenlehrer und Philosophen der Spätantike. Seine Werke "Confessiones" (Bekenntnisse) und "De civitate Dei" (Vom Gottesstaat) gehören zu den einflussreichsten Schriften der abendländischen Geistesgeschichte.
|
||||
|
||||
## Jugend und Irrwege (354-386)
|
||||
|
||||
### Herkunft und Familie
|
||||
Geboren in Thagaste (heute Souk Ahras, Algerien) als Sohn des Heiden Patricius und der Christin Monica. Die Mutter prägte ihn religiös, doch er wandte sich zunächst ab.
|
||||
|
||||
### Student in Karthago
|
||||
Studium der Rhetorik in Karthago ab 371. Leben in Ausschweifung und Konkubinat. Geburt des Sohnes Adeodatus 372. Lektüre von Ciceros "Hortensius" weckt philosophischen Hunger.
|
||||
|
||||
### Manichäer-Zeit
|
||||
Neun Jahre Anhänger der manichäischen Sekte, die Gut und Böse als kosmische Prinzipien lehrte. Zunehmende Zweifel an deren Lehren.
|
||||
|
||||
### Lehrertätigkeit
|
||||
Rhetoriklehrer in Karthago, Rom (383) und schließlich Mailand (384). Begegnung mit Bischof Ambrosius wird entscheidend.
|
||||
|
||||
## Die Bekehrung (386)
|
||||
|
||||
### Krise und Wandlung
|
||||
Intensive philosophische Studien (Neuplatonismus). Innerer Kampf zwischen Fleisch und Geist. Berühmte Szene im Mailänder Garten: Kinderstimme ruft "Tolle lege!" (Nimm und lies!).
|
||||
|
||||
### Taufbereitung
|
||||
Öffnet die Bibel und liest Römer 13,13-14. Entscheidung für Christentum. Rückzug mit Freunden nach Cassiciacum. Taufe durch Ambrosius in der Osternacht 387.
|
||||
|
||||
## Rückkehr nach Afrika (387-395)
|
||||
|
||||
### Verluste
|
||||
Tod der Mutter Monica in Ostia 387. Tod des Sohnes Adeodatus 389. Augustinus gründet klösterliche Gemeinschaft in Thagaste.
|
||||
|
||||
### Unfreiwillige Priesterweihe
|
||||
391 in Hippo Regius (heute Annaba, Algerien) vom Volk zum Priester gewählt. Prediger-Tätigkeit beginnt.
|
||||
|
||||
### Bischof von Hippo (395)
|
||||
Wird Bischof von Hippo. Gründet Kloster im Bischofshaus. Beginnt sein gewaltiges literarisches Werk.
|
||||
|
||||
## Die großen Werke
|
||||
|
||||
### Confessiones (397-401)
|
||||
Autobiographische Bekenntnisse:
|
||||
- Erste große Autobiographie der Weltliteratur
|
||||
- Psychologische Selbstanalyse
|
||||
- Theologie der Gnade
|
||||
- Philosophie der Zeit (Buch XI)
|
||||
- "Spät hab ich dich geliebt, du Schönheit..."
|
||||
|
||||
### De civitate Dei (413-426)
|
||||
Geschichtstheologie in 22 Büchern:
|
||||
- Antwort auf Vorwurf, Christentum habe Rom geschwächt
|
||||
- Civitas Dei vs. Civitas terrena
|
||||
- Zwei Staaten, zwei Liebensweisen
|
||||
- Geschichtsphilosophie
|
||||
- Legitimation weltlicher Ordnung
|
||||
|
||||
### De Trinitate (399-419)
|
||||
Trinitätslehre:
|
||||
- Psychologische Trinität im Menschen
|
||||
- Memoria, Intelligentia, Voluntas
|
||||
- Einheit in Dreiheit
|
||||
- Grundlage scholastischer Theologie
|
||||
|
||||
## Theologische Kämpfe
|
||||
|
||||
### Donatisten-Streit
|
||||
Kampf gegen schismatische nordafrikanische Kirche:
|
||||
- Frage der Sakramentsgültigkeit
|
||||
- Kirchenverständnis
|
||||
- Rechtfertigung von Zwang gegen Häretiker
|
||||
|
||||
### Pelagianismus
|
||||
Hauptgegner: Pelagius und seine Anhänger:
|
||||
- Erbsündenlehre entwickelt
|
||||
- Gnadenlehre verfeinert
|
||||
- Prädestinationslehre
|
||||
- Freier Wille und Gnade
|
||||
- Einfluss bis Reformation
|
||||
|
||||
## Philosophisches Denken
|
||||
|
||||
### Neuplatonismus und Christentum
|
||||
Verbindung von:
|
||||
- Plotins Emanationslehre
|
||||
- Christlicher Schöpfungsglaube
|
||||
- Erkenntnistheorie der Erleuchtung
|
||||
- Gott als höchstes Sein und Gut
|
||||
|
||||
### Zeit und Ewigkeit
|
||||
Revolutionäre Zeitphilosophie:
|
||||
- Zeit als Ausdehnung der Seele
|
||||
- Gegenwart des Vergangenen (Erinnerung)
|
||||
- Gegenwart des Zukünftigen (Erwartung)
|
||||
- Ewigkeit als Gleichzeitigkeit
|
||||
|
||||
### Zeichentheorie
|
||||
Grundlagen der Semiotik:
|
||||
- Unterscheidung von Zeichen und Bezeichnetem
|
||||
- Sprachphilosophie
|
||||
- Hermeneutik der Schriftauslegung
|
||||
|
||||
## Anthropologie und Psychologie
|
||||
|
||||
### Selbsterkenntnis
|
||||
"In te ipsum redi" - Kehre in dich selbst ein:
|
||||
- Innerer Mensch als Ort der Wahrheit
|
||||
- Selbstreflexion als Weg zu Gott
|
||||
- Psychologie der Innerlichkeit
|
||||
|
||||
### Willenslehre
|
||||
Revolutionäre Willenspsychologie:
|
||||
- Wille wichtiger als Intellekt
|
||||
- Gespaltener Wille
|
||||
- Liebe als Grundkraft
|
||||
- "Pondus meum amor meus"
|
||||
|
||||
### Erbsünde
|
||||
Dunkle Seite seiner Lehre:
|
||||
- Erbschuld aller Menschen
|
||||
- Massa damnata
|
||||
- Nur Taufe rettet
|
||||
- Unbarmherzigkeit gegenüber ungetauften Kindern
|
||||
|
||||
## Gnadenlehre
|
||||
|
||||
### Kernsätze
|
||||
Fundamentale Einsichten:
|
||||
- "Da quod iubes et iube quod vis" - Gib was du befiehlst
|
||||
- Gnade kommt zuvor (gratia praeveniens)
|
||||
- Gnade wirkt (gratia operans)
|
||||
- Menschliche Mitwirkung (gratia cooperans)
|
||||
|
||||
### Prädestination
|
||||
Umstrittene Konsequenzen:
|
||||
- Erwählung vor der Welt
|
||||
- Gnadenwahl unergründlich
|
||||
- Anzahl der Geretteten festgelegt
|
||||
- Basis für Calvin und Jansenismus
|
||||
|
||||
## Tod und Vermächtnis (430)
|
||||
|
||||
### Letzte Tage
|
||||
Während der Belagerung Hippos durch die Vandalen stirbt Augustinus am 28. August 430 im Alter von 75 Jahren. Seine Klosterbibliothek übersteht die Eroberung.
|
||||
|
||||
### Sofortige Wirkung
|
||||
Schon zu Lebzeiten wichtigster lateinischer Kirchenvater. Über 100 erhaltene Schriften, 500 Predigten, 200 Briefe.
|
||||
|
||||
## Einfluss durch die Jahrhunderte
|
||||
|
||||
### Mittelalter
|
||||
Dominiert die Scholastik:
|
||||
- Bonaventura und Franziskaner
|
||||
- Duns Scotus
|
||||
- Kampf mit Aristotelismus
|
||||
- Augustinereremitenorden (1256)
|
||||
|
||||
### Reformation
|
||||
Luther als Augustinermönch:
|
||||
- "Allein durch Gnade"
|
||||
- Bibelautorität
|
||||
- Rechtfertigungslehre
|
||||
- Calvin radikalisiert Prädestination
|
||||
|
||||
### Neuzeit
|
||||
Fortgesetzte Wirkung:
|
||||
- Pascal und Jansenismus
|
||||
- Existenzphilosophie (Kierkegaard, Heidegger)
|
||||
- Personalismus
|
||||
- Selbstreflexion als Methode
|
||||
|
||||
## Kritik und Probleme
|
||||
|
||||
### Negative Aspekte
|
||||
Problematische Lehren:
|
||||
- Verdammung ungetaufter Kinder
|
||||
- Rechtfertigung von Zwang
|
||||
- Sexualnegativität
|
||||
- Determinismus
|
||||
|
||||
### Moderne Rezeption
|
||||
Ambivalentes Erbe:
|
||||
- Tiefenpsychologie avant la lettre
|
||||
- Aber auch Angstreligion
|
||||
- Freiheitsphilosophie und Determinismus
|
||||
- Subjektivität und Autorität
|
||||
|
||||
## Berühmte Zitate
|
||||
|
||||
> "Unruhig ist unser Herz, bis es Ruhe findet in dir."
|
||||
|
||||
> "Liebe, und tu was du willst."
|
||||
|
||||
> "Spät hab ich dich geliebt, du Schönheit, so alt und so neu."
|
||||
|
||||
> "Du hast uns zu dir hin geschaffen, und unruhig ist unser Herz, bis es Ruhe findet in dir."
|
||||
|
||||
## Das bleibende Vermächtnis
|
||||
|
||||
Augustinus bleibt der einflussreichste Kirchenvater des Westens:
|
||||
|
||||
- **Psychologe der Innerlichkeit** - Erfinder der Selbstreflexion
|
||||
- **Philosoph der Zeit** - Grundlage moderner Zeittheorie
|
||||
- **Theologe der Gnade** - Prägte Christentum für Jahrhunderte
|
||||
- **Schriftsteller** - Confessiones als literarisches Meisterwerk
|
||||
|
||||
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
|
||||
*29. Mai 1917 - 22. November 1963*
|
||||
|
||||
## Kurzbiografie
|
||||
|
||||
John F. Kennedy, der 35. Präsident der Vereinigten Staaten (1961-1963), verkörperte eine neue Generation amerikanischer Politik. Als jüngster gewählter und erster katholischer Präsident inspirierte er mit seiner Vision einer "New Frontier" und seiner charismatischen Führung die Welt. Seine Ermordung in Dallas beendete abrupt eine Präsidentschaft, die trotz ihrer Kürze die amerikanische Geschichte nachhaltig prägte.
|
||||
|
||||
## Kennedy-Clan und frühe Jahre (1917-1940)
|
||||
|
||||
### Die Kennedy-Dynastie
|
||||
Geboren als zweiter Sohn von Joseph P. Kennedy Sr. und Rose Fitzgerald Kennedy in Brookline, Massachusetts. Vater Joe war erfolgreicher Geschäftsmann und Diplomat, die Mutter entstammte einer prominenten irisch-amerikanischen Politikerfamilie Bostons.
|
||||
|
||||
### Privilegierte Kindheit
|
||||
Aufwachsen im Wohlstand mit acht Geschwistern. Besuch exklusiver Privatschulen. Früh eingeübt in Wettbewerb und Leistungsdruck durch den ehrgeizigen Vater, der seine Söhne für die Politik vorbereitete.
|
||||
|
||||
### Gesundheitsprobleme
|
||||
Litt zeitlebens unter schweren Rückenproblemen und der Addison-Krankheit. Häufige Krankenhausaufenthalte prägten seine Jugend, entwickelte aber Willensstärke und Durchhaltevermögen.
|
||||
|
||||
## Ausbildung und Krieg (1936-1945)
|
||||
|
||||
### Harvard-Student
|
||||
Studium an der Harvard University. Abschlussarbeit über britische Appeasement-Politik wird als Buch "Why England Slept" (1940) veröffentlicht und ein Bestseller.
|
||||
|
||||
### PT-109 Held
|
||||
Navy-Offizier im Pazifik-Krieg. August 1943: Sein Schnellboot PT-109 wird von japanischem Zerstörer gerammt. Rettet verletzten Kameraden, schwimmt stundenlang mit Riemen zwischen den Zähnen. Ausgezeichnet als Kriegsheld.
|
||||
|
||||
### Journalistische Tätigkeit
|
||||
Nach dem Krieg kurzzeitig Journalist. Berichtet 1945 über Gründung der UN. Tod des älteren Bruders Joe Jr. 1944 (Kriegseinsatz) macht John zum designierten Politiker der Familie.
|
||||
|
||||
## Politischer Aufstieg (1946-1960)
|
||||
|
||||
### Kongressabgeordneter (1947-1953)
|
||||
Wahl ins Repräsentantenhaus für Massachusetts mit 29 Jahren. Liberaler Demokrat, aber pragmatisch. Unterstützt Sozialreformen und starke Verteidigung.
|
||||
|
||||
### Senator (1953-1960)
|
||||
Wahl in den Senat 1952. Heirat mit Jacqueline Bouvier 1953. Schwere Rückenoperationen 1954/55. Während Genesung Arbeit an "Profiles in Courage" (Pulitzer-Preis 1957).
|
||||
|
||||
### Fast Vizepräsident
|
||||
1956 knapp an Vizepräsidentschaftskandidatur gescheitert. Nutzung der Zeit für Aufbau nationaler Bekanntheit. Bereitet systematisch Präsidentschaftskampagne vor.
|
||||
|
||||
## Präsidentschaftswahlkampf 1960
|
||||
|
||||
### Demokratische Vorwahlen
|
||||
Überwindet Vorbehalte gegen Katholiken. Sieg in West Virginia entscheidend. Nominierung durch Parteitag mit Lyndon B. Johnson als Running Mate.
|
||||
|
||||
### TV-Debatten
|
||||
Erste Fernsehduelle der Geschichte gegen Richard Nixon. Kennedys jugendliche Ausstrahlung vs. Nixons fahles Aussehen. Radio-Hörer sehen Nixon vorn, TV-Zuschauer Kennedy - Medium als Botschaft.
|
||||
|
||||
### Knapper Sieg
|
||||
Hauchdünner Sieg im November 1960: 49,7% vs. 49,5%. Jüngster gewählter Präsident mit 43 Jahren (Theodore Roosevelt war jünger bei Amtsantritt nach Attentat).
|
||||
|
||||
## Die Präsidentschaft (1961-1963)
|
||||
|
||||
### Antrittsrede (20. Januar 1961)
|
||||
Legendäre Inaugurationsrede:
|
||||
> "Fragt nicht, was euer Land für euch tun kann - fragt, was ihr für euer Land tun könnt."
|
||||
|
||||
> "Lasst uns niemals aus Furcht verhandeln. Aber lasst uns niemals Furcht haben zu verhandeln."
|
||||
|
||||
Vision einer "New Frontier" - neue Grenzen zu überschreiten.
|
||||
|
||||
### Schweinebuchtkrise (April 1961)
|
||||
Erste außenpolitische Katastrophe: Gescheiterte CIA-Invasion Kubas. Kennedy übernimmt Verantwortung, lernt aus Fehler. Misstrauen gegenüber Militär und Geheimdiensten wächst.
|
||||
|
||||
### Wiener Gipfel (Juni 1961)
|
||||
Treffen mit Chruschtschow in Wien. Sowjetführer testet den Neuling. Kennedy fühlt sich überrumpelt. Berlin-Frage ungeklärt.
|
||||
|
||||
### Berlinkrise und Mauerbau
|
||||
Bau der Berliner Mauer August 1961. Kennedy entsetzt, aber militärisch machtlos. Bekräftigt Verteidigungsgarantie für West-Berlin.
|
||||
|
||||
### Kuba-Krise (Oktober 1962)
|
||||
Gefährlichste Krise des Kalten Krieges:
|
||||
- Sowjetische Atomraketen auf Kuba entdeckt
|
||||
- Seekblockade statt Luftangriff gewählt
|
||||
- 13 Tage am Rand des Atomkriegs
|
||||
- Geheimer Deal: Raketen gegen US-Jupiter aus Türkei
|
||||
- Chruschtschow lenkt ein
|
||||
|
||||
Kennedys Krisenbewältigung wird zur Sternstunde: Festigkeit plus Flexibilität, militärischer Druck plus diplomatische Lösung.
|
||||
|
||||
### Friedenspolitik (1963)
|
||||
Nach Kubakrise Entspannungsphase:
|
||||
- "Strategie des Friedens" (American University Speech, Juni 1963)
|
||||
- Atomteststopp-Vertrag (August 1963)
|
||||
- "Heißer Draht" nach Moskau
|
||||
- Umdenken über Vietnam beginnt
|
||||
|
||||
## Innenpolitik
|
||||
|
||||
### New Frontier Programm
|
||||
Ambitionierte Agenda:
|
||||
- Steuerreformen
|
||||
- Armutsbekämpfung
|
||||
- Bildungsreform
|
||||
- Medicare-Vorschläge
|
||||
|
||||
Viele Gesetze scheitern im Kongress. Demokratische Südstaatler blockieren Reformen.
|
||||
|
||||
### Bürgerrechte
|
||||
Zunächst zögerlich, dann engagiert:
|
||||
- Entsendung der National Guard in Alabama (1963)
|
||||
- Unterstützung für James Meredith (Mississippi)
|
||||
- TV-Ansprache zu Moral der Bürgerrechte (Juni 1963)
|
||||
- Vorlage des Civil Rights Act (wird erst 1964 unter Johnson Gesetz)
|
||||
|
||||
### Raumfahrt
|
||||
Vision der Mondlandung:
|
||||
> "Wir haben beschlossen, in diesem Jahrzehnt zum Mond zu fliegen, nicht weil es leicht ist, sondern weil es schwer ist."
|
||||
|
||||
Massives Apollo-Programm gestartet. Ziel 1969 erreicht.
|
||||
|
||||
### Wirtschaft
|
||||
- Längste Wirtschaftsexpansion der Nachkriegszeit
|
||||
- Steuersenkungen zur Konjunkturbelebung
|
||||
- Keynesianische Politik
|
||||
|
||||
## Familie im Weißen Haus
|
||||
|
||||
### Jackie Kennedy
|
||||
Erste Lady Jacqueline als Stil-Ikone. Restaurierung des Weißen Hauses. Fernsehführung durch Präsidentenresidenz. Kulturelle Renaissance.
|
||||
|
||||
### Camelot-Image
|
||||
Weißes Haus wird zu "Camelot" - Anspielung auf König Arthurs Hof. Intellektuelle, Künstler, Nobelpreisträger zu Gast. Pablo Casals spielt Cello.
|
||||
|
||||
### Kinder
|
||||
Caroline (geb. 1957) und John Jr. (geb. 1960, gestorben 1999) im Weißen Haus. Sohn Patrick stirbt nach zwei Tagen (August 1963).
|
||||
|
||||
## Das Attentat (22. November 1963)
|
||||
|
||||
### Dallas, Texas
|
||||
Wahlkampfreise nach Texas zur Aussöhnung verfeindeter Demokraten-Flügel. Trotz Warnungen offene Limousine durch Dallas.
|
||||
|
||||
### 12:30 Uhr
|
||||
Dealey Plaza: Schüsse fallen. Kennedy in Kopf und Hals getroffen. Gouverneur Connally ebenfalls verletzt. Fahrt zum Parkland Hospital.
|
||||
|
||||
### Tod des Präsidenten
|
||||
13:00 Uhr für tot erklärt. Jacqueline Kennedy im blutbefleckten Kostüm. Lyndon Johnson wird vereidigt.
|
||||
|
||||
### Lee Harvey Oswald
|
||||
Verhaftung des mutmaßlichen Täters. Zwei Tage später erschießt Jack Ruby Oswald vor laufender Kamera. Unzählige Verschwörungstheorien bis heute.
|
||||
|
||||
## Vermächtnis
|
||||
|
||||
### Warren-Kommission
|
||||
Offizielle Untersuchung: Oswald als Einzeltäter. Viele Zweifel bleiben. Skeptizismus gegenüber offizieller Version bis heute.
|
||||
|
||||
### Was wäre wenn?
|
||||
Spekulation über unvollendete Präsidentschaft:
|
||||
- Hätte er USA aus Vietnam herausgehalten?
|
||||
- Welche Bürgerrechtsgesetze hätte er durchgesetzt?
|
||||
- Wie wäre das Verhältnis zur Sowjetunion?
|
||||
|
||||
### Mythos und Realität
|
||||
Komplexes Erbe:
|
||||
- **Licht**: Charisma, Vision, Krisenmanagement, Inspiration
|
||||
- **Schatten**: Eheliche Untreue, Gesundheitsprobleme verheimlicht, anfängliches Zögern bei Bürgerrechten
|
||||
|
||||
### Fortdauernde Inspiration
|
||||
Kennedy-Mystik lebt:
|
||||
- Jugend, Hoffnung, Idealismus
|
||||
- Ruf zum Dienst an der Gemeinschaft
|
||||
- Friedenskorps als dauerhaftes Vermächtnis
|
||||
- "Ask not..." als Appell an jede Generation
|
||||
|
||||
## Berühmte Reden und Zitate
|
||||
|
||||
> "Ich bin ein Berliner!" (26. Juni 1963, West-Berlin)
|
||||
|
||||
> "Eine steigende Flut hebt alle Boote." (über Wirtschaftswachstum)
|
||||
|
||||
> "Die Menschheit muss dem Krieg ein Ende setzen, oder der Krieg setzt der Menschheit ein Ende."
|
||||
|
||||
> "Jeder Mensch kann einen Unterschied machen und jeder Mensch sollte es versuchen."
|
||||
|
||||
## Das Kennedy-Vermächtnis
|
||||
|
||||
Über 60 Jahre nach seiner Ermordung bleibt JFK eine der faszinierendsten Figuren amerikanischer Geschichte:
|
||||
|
||||
- **Symbol unerfüllten Potenzials** - Was hätte sein können
|
||||
- **Wendepunkt der Moderne** - Ende der Unschuld Amerikas
|
||||
- **Inspiration für Generationen** - Ruf zum öffentlichen Dienst
|
||||
- **Warnung** - Fragilität von Leben und Demokratie
|
||||
|
||||
Kennedy verkörperte den Optimismus und die Zuversicht einer Ära, die mit ihm zu Ende ging. Sein früher Tod verwandelte einen guten Präsidenten in eine Legende und ließ seine Vision einer besseren Welt als unerfülltes Versprechen zurück.`,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('📝 Füge ausführliche Biografien hinzu\n');
|
||||
|
||||
const authorsDE = loadAuthors('de');
|
||||
const authorsEN = loadAuthors('en');
|
||||
|
||||
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 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`);
|
||||
|
||||
writeAuthors(updatedDE, 'de');
|
||||
writeAuthors(updatedEN, 'en');
|
||||
|
||||
console.log('\n✨ Fertig!\n');
|
||||
}
|
||||
|
||||
main();
|
||||
97
apps/quote/apps/mobile/scripts/analyzeQuoteCounts.ts
Normal file
97
apps/quote/apps/mobile/scripts/analyzeQuoteCounts.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
#!/usr/bin/env npx tsx
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
async function analyzeQuoteCounts() {
|
||||
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;
|
||||
}
|
||||
|
||||
analyzeQuoteCounts().catch(error => {
|
||||
console.error('❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Add Buddha and Nietzsche quotes - major expansion
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
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;
|
||||
}
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
addMoreQuotes();
|
||||
}
|
||||
|
||||
module.exports = { addMoreQuotes };
|
||||
281
apps/quote/apps/mobile/scripts/archive/addMajorAuthorsQuotes.js
Normal file
281
apps/quote/apps/mobile/scripts/archive/addMajorAuthorsQuotes.js
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Add quotes for major philosophers and authors who need more content
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
// 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
|
||||
}
|
||||
];
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
addMajorAuthorsQuotes();
|
||||
}
|
||||
|
||||
module.exports = { addMajorAuthorsQuotes };
|
||||
281
apps/quote/apps/mobile/scripts/archive/addMoreEinsteinQuotes.js
Normal file
281
apps/quote/apps/mobile/scripts/archive/addMoreEinsteinQuotes.js
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Add more Albert Einstein quotes - one of the most popular authors
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
addMoreEinsteinQuotes();
|
||||
}
|
||||
|
||||
module.exports = { addMoreEinsteinQuotes };
|
||||
215
apps/quote/apps/mobile/scripts/archive/addShakespeareQuotes.js
Normal file
215
apps/quote/apps/mobile/scripts/archive/addShakespeareQuotes.js
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Add famous Shakespeare quotes - he currently has 0 quotes!
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
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')}`;
|
||||
}
|
||||
|
||||
// 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"]
|
||||
}
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
addShakespeareQuotes();
|
||||
}
|
||||
|
||||
module.exports = { addShakespeareQuotes };
|
||||
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