mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 03:16:44 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -17,6 +17,7 @@ apps/presi/
|
|||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
pnpm presi:dev # Run all presi apps
|
||||
pnpm dev:presi:mobile # Start mobile app
|
||||
|
|
@ -29,6 +30,7 @@ pnpm presi:db:seed # Seed database with sample data
|
|||
```
|
||||
|
||||
### Mobile App (apps/presi/apps/mobile)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
|
|
@ -36,6 +38,7 @@ pnpm android # Run on Android emulator
|
|||
```
|
||||
|
||||
### Web App (apps/presi/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (port 5178)
|
||||
pnpm build # Build for production
|
||||
|
|
@ -44,6 +47,7 @@ pnpm check # Run svelte-check
|
|||
```
|
||||
|
||||
### Backend (apps/presi/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
|
|
@ -63,6 +67,7 @@ pnpm db:seed # Seed database
|
|||
## Architecture
|
||||
|
||||
### Core Features
|
||||
|
||||
- Create and manage presentation decks
|
||||
- Add and edit slides with various content types
|
||||
- Apply themes to presentations
|
||||
|
|
@ -71,27 +76,28 @@ pnpm db:seed # Seed database
|
|||
|
||||
### Backend API Endpoints
|
||||
|
||||
| Endpoint | Method | Auth | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `/api/health` | GET | No | Health check |
|
||||
| `/api/decks` | GET | Yes | Get user's decks |
|
||||
| `/api/decks` | POST | Yes | Create new deck |
|
||||
| `/api/decks/:id` | GET | Yes | Get deck details |
|
||||
| `/api/decks/:id` | PUT | Yes | Update deck |
|
||||
| `/api/decks/:id` | DELETE | Yes | Delete deck |
|
||||
| `/api/decks/:id/slides` | GET | Yes | Get slides for deck |
|
||||
| `/api/decks/:id/slides` | POST | Yes | Add slide to deck |
|
||||
| `/api/slides/:id` | PUT | Yes | Update slide |
|
||||
| `/api/slides/:id` | DELETE | Yes | Delete slide |
|
||||
| `/api/slides/reorder` | PUT | Yes | Reorder slides |
|
||||
| `/api/share/:code` | GET | No | Get shared deck (public) |
|
||||
| `/api/share/deck/:id` | POST | Yes | Create share link |
|
||||
| `/api/share/deck/:id/links` | GET | Yes | Get share links for deck |
|
||||
| `/api/share/:shareId` | DELETE | Yes | Delete share link |
|
||||
| Endpoint | Method | Auth | Description |
|
||||
| --------------------------- | ------ | ---- | ------------------------ |
|
||||
| `/api/health` | GET | No | Health check |
|
||||
| `/api/decks` | GET | Yes | Get user's decks |
|
||||
| `/api/decks` | POST | Yes | Create new deck |
|
||||
| `/api/decks/:id` | GET | Yes | Get deck details |
|
||||
| `/api/decks/:id` | PUT | Yes | Update deck |
|
||||
| `/api/decks/:id` | DELETE | Yes | Delete deck |
|
||||
| `/api/decks/:id/slides` | GET | Yes | Get slides for deck |
|
||||
| `/api/decks/:id/slides` | POST | Yes | Add slide to deck |
|
||||
| `/api/slides/:id` | PUT | Yes | Update slide |
|
||||
| `/api/slides/:id` | DELETE | Yes | Delete slide |
|
||||
| `/api/slides/reorder` | PUT | Yes | Reorder slides |
|
||||
| `/api/share/:code` | GET | No | Get shared deck (public) |
|
||||
| `/api/share/deck/:id` | POST | Yes | Create share link |
|
||||
| `/api/share/deck/:id/links` | GET | Yes | Get share links for deck |
|
||||
| `/api/share/:shareId` | DELETE | Yes | Delete share link |
|
||||
|
||||
### Data Models
|
||||
|
||||
**Deck** - Presentation deck
|
||||
|
||||
- `id` (string) - Unique identifier
|
||||
- `userId` (string) - Owner user ID
|
||||
- `title` (string) - Deck title
|
||||
|
|
@ -101,6 +107,7 @@ pnpm db:seed # Seed database
|
|||
- `createdAt` / `updatedAt` (timestamps)
|
||||
|
||||
**Slide** - Individual slide in a deck
|
||||
|
||||
- `id` (string) - Unique identifier
|
||||
- `deckId` (string) - Parent deck reference
|
||||
- `order` (number) - Position in deck
|
||||
|
|
@ -108,13 +115,16 @@ pnpm db:seed # Seed database
|
|||
- `createdAt` (timestamp)
|
||||
|
||||
**SlideContent** - Content structure
|
||||
|
||||
- `type`: 'title' | 'content' | 'image' | 'split'
|
||||
- `title`, `subtitle`, `body`, `imageUrl`, `bulletPoints`
|
||||
|
||||
**Theme** - Visual theme
|
||||
|
||||
- `id`, `name`, `colors`, `fonts`, `isDefault`
|
||||
|
||||
**SharedDeck** - Share link for deck
|
||||
|
||||
- `id` (string) - Unique identifier
|
||||
- `deckId` (string) - Reference to deck
|
||||
- `shareCode` (string) - Unique share code (12 chars)
|
||||
|
|
@ -124,6 +134,7 @@ pnpm db:seed # Seed database
|
|||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
|
||||
```
|
||||
NODE_ENV=development
|
||||
PORT=3008
|
||||
|
|
@ -133,12 +144,14 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8081
|
|||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3008
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
#### Web (.env)
|
||||
|
||||
```
|
||||
PUBLIC_BACKEND_URL=http://localhost:3008
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
|
@ -147,14 +160,17 @@ PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
|||
## Shared Package
|
||||
|
||||
### @presi/shared
|
||||
|
||||
Located at `packages/shared/`
|
||||
|
||||
**Types:**
|
||||
|
||||
- `Deck`, `Slide`, `SlideContent`
|
||||
- `Theme`, `ThemeColors`, `ThemeFonts`
|
||||
- `SharedDeck` (for sharing feature)
|
||||
|
||||
**DTOs:**
|
||||
|
||||
- `CreateDeckDto`, `UpdateDeckDto`
|
||||
- `CreateSlideDto`, `UpdateSlideDto`
|
||||
- `ReorderSlidesDto`
|
||||
|
|
@ -186,6 +202,7 @@ The SvelteKit web app provides feature parity with the mobile app:
|
|||
- **Settings**: Theme switching (light/dark/system), account info
|
||||
|
||||
### Web App Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import 'dotenv/config';
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,42 @@
|
|||
{
|
||||
"name": "@presi/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",
|
||||
"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",
|
||||
"@presi/shared": "workspace:*",
|
||||
"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",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@presi/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",
|
||||
"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",
|
||||
"@presi/shared": "workspace:*",
|
||||
"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",
|
||||
"nanoid": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ import { ShareModule } from './share/share.module';
|
|||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
DeckModule,
|
||||
SlideModule,
|
||||
ThemeModule,
|
||||
ShareModule,
|
||||
HealthModule,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
DeckModule,
|
||||
SlideModule,
|
||||
ThemeModule,
|
||||
ShareModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,67 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid authorization header');
|
||||
}
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const payload = this.verifyToken(token);
|
||||
request.user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = this.verifyToken(token);
|
||||
request.user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private verifyToken(token: string): { sub: string; email: string; role: string } {
|
||||
const publicKeyPem = this.configService.get<string>('JWT_PUBLIC_KEY');
|
||||
if (!publicKeyPem) {
|
||||
throw new Error('JWT_PUBLIC_KEY not configured');
|
||||
}
|
||||
private verifyToken(token: string): { sub: string; email: string; role: string } {
|
||||
const publicKeyPem = this.configService.get<string>('JWT_PUBLIC_KEY');
|
||||
if (!publicKeyPem) {
|
||||
throw new Error('JWT_PUBLIC_KEY not configured');
|
||||
}
|
||||
|
||||
// Decode token parts
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
// Decode token parts
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
|
||||
// Verify signature using RS256
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(`${headerB64}.${payloadB64}`);
|
||||
// Verify signature using RS256
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(`${headerB64}.${payloadB64}`);
|
||||
|
||||
const publicKey = publicKeyPem.replace(/\\n/g, '\n');
|
||||
const signature = Buffer.from(signatureB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
const publicKey = publicKeyPem.replace(/\\n/g, '\n');
|
||||
const signature = Buffer.from(signatureB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
|
||||
if (!verifier.verify(publicKey, signature)) {
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
if (!verifier.verify(publicKey, signature)) {
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
|
||||
// Decode and parse payload
|
||||
const payloadJson = Buffer.from(
|
||||
payloadB64.replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64',
|
||||
).toString('utf-8');
|
||||
const payload = JSON.parse(payloadJson);
|
||||
// Decode and parse payload
|
||||
const payloadJson = Buffer.from(
|
||||
payloadB64.replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64'
|
||||
).toString('utf-8');
|
||||
const payload = JSON.parse(payloadJson);
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
throw new Error('Token expired');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,30 +9,30 @@ let connection: ReturnType<typeof postgres> | null = null;
|
|||
let db: PostgresJsDatabase<typeof schema> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string): PostgresJsDatabase<typeof schema> {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = PostgresJsDatabase<typeof schema>;
|
||||
|
|
|
|||
|
|
@ -6,23 +6,23 @@ export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@ import { themes } from './themes.schema';
|
|||
import { sharedDecks } from './shared-decks.schema';
|
||||
|
||||
export const decks = pgTable('decks', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
themeId: uuid('theme_id').references(() => themes.id),
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
themeId: uuid('theme_id').references(() => themes.id),
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const decksRelations = relations(decks, ({ many, one }) => ({
|
||||
slides: many(slides),
|
||||
theme: one(themes, {
|
||||
fields: [decks.themeId],
|
||||
references: [themes.id],
|
||||
}),
|
||||
sharedDecks: many(sharedDecks),
|
||||
slides: many(slides),
|
||||
theme: one(themes, {
|
||||
fields: [decks.themeId],
|
||||
references: [themes.id],
|
||||
}),
|
||||
sharedDecks: many(sharedDecks),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import { relations } from 'drizzle-orm';
|
|||
import { decks } from './decks.schema';
|
||||
|
||||
export const sharedDecks = pgTable('shared_decks', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
deckId: uuid('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
shareCode: text('share_code').notNull().unique(),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
deckId: uuid('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
shareCode: text('share_code').notNull().unique(),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const sharedDecksRelations = relations(sharedDecks, ({ one }) => ({
|
||||
deck: one(decks, {
|
||||
fields: [sharedDecks.deckId],
|
||||
references: [decks.id],
|
||||
}),
|
||||
deck: one(decks, {
|
||||
fields: [sharedDecks.deckId],
|
||||
references: [decks.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,25 +3,25 @@ import { relations } from 'drizzle-orm';
|
|||
import { decks } from './decks.schema';
|
||||
|
||||
export const slides = pgTable('slides', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
deckId: uuid('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
order: integer('order').notNull(),
|
||||
content: jsonb('content').$type<{
|
||||
type: 'title' | 'content' | 'image' | 'split';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
bulletPoints?: string[];
|
||||
}>(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
deckId: uuid('deck_id')
|
||||
.notNull()
|
||||
.references(() => decks.id, { onDelete: 'cascade' }),
|
||||
order: integer('order').notNull(),
|
||||
content: jsonb('content').$type<{
|
||||
type: 'title' | 'content' | 'image' | 'split';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
bulletPoints?: string[];
|
||||
}>(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const slidesRelations = relations(slides, ({ one }) => ({
|
||||
deck: one(decks, {
|
||||
fields: [slides.deckId],
|
||||
references: [decks.id],
|
||||
}),
|
||||
deck: one(decks, {
|
||||
fields: [slides.deckId],
|
||||
references: [decks.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import { relations } from 'drizzle-orm';
|
|||
import { decks } from './decks.schema';
|
||||
|
||||
export const themes = pgTable('themes', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
colors: jsonb('colors').$type<{
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
text: string;
|
||||
accent: string;
|
||||
}>(),
|
||||
fonts: jsonb('fonts').$type<{
|
||||
heading: string;
|
||||
body: string;
|
||||
}>(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
colors: jsonb('colors').$type<{
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
text: string;
|
||||
accent: string;
|
||||
}>(),
|
||||
fonts: jsonb('fonts').$type<{
|
||||
heading: string;
|
||||
body: string;
|
||||
}>(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
});
|
||||
|
||||
export const themesRelations = relations(themes, ({ many }) => ({
|
||||
decks: many(decks),
|
||||
decks: many(decks),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { DeckService } from './deck.service';
|
||||
import { CreateDeckDto, UpdateDeckDto } from './deck.dto';
|
||||
|
|
@ -16,43 +16,34 @@ import { AuthGuard } from '../auth/auth.guard';
|
|||
@Controller('decks')
|
||||
@UseGuards(AuthGuard)
|
||||
export class DeckController {
|
||||
constructor(private readonly deckService: DeckService) {}
|
||||
constructor(private readonly deckService: DeckService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: { user: { sub: string } }) {
|
||||
return this.deckService.findByUser(req.user.sub);
|
||||
}
|
||||
@Get()
|
||||
async findAll(@Request() req: { user: { sub: string } }) {
|
||||
return this.deckService.findByUser(req.user.sub);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.deckService.findOneWithSlides(id, req.user.sub);
|
||||
}
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @Request() req: { user: { sub: string } }) {
|
||||
return this.deckService.findOneWithSlides(id, req.user.sub);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Body() createDeckDto: CreateDeckDto,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.deckService.create(req.user.sub, createDeckDto);
|
||||
}
|
||||
@Post()
|
||||
async create(@Body() createDeckDto: CreateDeckDto, @Request() req: { user: { sub: string } }) {
|
||||
return this.deckService.create(req.user.sub, createDeckDto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateDeckDto: UpdateDeckDto,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.deckService.update(id, req.user.sub, updateDeckDto);
|
||||
}
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateDeckDto: UpdateDeckDto,
|
||||
@Request() req: { user: { sub: string } }
|
||||
) {
|
||||
return this.deckService.update(id, req.user.sub, updateDeckDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.deckService.remove(id, req.user.sub);
|
||||
}
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string, @Request() req: { user: { sub: string } }) {
|
||||
return this.deckService.remove(id, req.user.sub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
import { IsString, IsOptional, IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateDeckDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
themeId?: string;
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
themeId?: string;
|
||||
}
|
||||
|
||||
export class UpdateDeckDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
title?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
themeId?: string;
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
themeId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublic?: boolean;
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { DeckController } from './deck.controller';
|
|||
import { DeckService } from './deck.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DeckController],
|
||||
providers: [DeckService],
|
||||
exports: [DeckService],
|
||||
controllers: [DeckController],
|
||||
providers: [DeckService],
|
||||
exports: [DeckService],
|
||||
})
|
||||
export class DeckModule {}
|
||||
|
|
|
|||
|
|
@ -7,106 +7,106 @@ import { CreateDeckDto, UpdateDeckDto } from './deck.dto';
|
|||
|
||||
@Injectable()
|
||||
export class DeckService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database
|
||||
) {}
|
||||
|
||||
async findByUser(userId: string) {
|
||||
return this.db.query.decks.findMany({
|
||||
where: eq(decks.userId, userId),
|
||||
orderBy: [desc(decks.updatedAt)],
|
||||
with: {
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async findByUser(userId: string) {
|
||||
return this.db.query.decks.findMany({
|
||||
where: eq(decks.userId, userId),
|
||||
orderBy: [desc(decks.updatedAt)],
|
||||
with: {
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOneWithSlides(id: string, userId: string) {
|
||||
const deck = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
with: {
|
||||
slides: {
|
||||
orderBy: [slides.order],
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
async findOneWithSlides(id: string, userId: string) {
|
||||
const deck = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
with: {
|
||||
slides: {
|
||||
orderBy: [slides.order],
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deck) {
|
||||
throw new NotFoundException('Deck not found');
|
||||
}
|
||||
if (!deck) {
|
||||
throw new NotFoundException('Deck not found');
|
||||
}
|
||||
|
||||
return deck;
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
return this.db.query.decks.findFirst({
|
||||
where: eq(decks.id, id),
|
||||
with: {
|
||||
slides: {
|
||||
orderBy: [slides.order],
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
async findOne(id: string) {
|
||||
return this.db.query.decks.findFirst({
|
||||
where: eq(decks.id, id),
|
||||
with: {
|
||||
slides: {
|
||||
orderBy: [slides.order],
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateDeckDto) {
|
||||
const [deck] = await this.db
|
||||
.insert(decks)
|
||||
.values({
|
||||
userId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
themeId: dto.themeId,
|
||||
})
|
||||
.returning();
|
||||
async create(userId: string, dto: CreateDeckDto) {
|
||||
const [deck] = await this.db
|
||||
.insert(decks)
|
||||
.values({
|
||||
userId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
themeId: dto.themeId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return deck;
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateDeckDto) {
|
||||
// Verify ownership
|
||||
const existing = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
});
|
||||
async update(id: string, userId: string, dto: UpdateDeckDto) {
|
||||
// Verify ownership
|
||||
const existing = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Deck not found');
|
||||
}
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Deck not found');
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(decks)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(decks.id, id))
|
||||
.returning();
|
||||
const [updated] = await this.db
|
||||
.update(decks)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(decks.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string) {
|
||||
// Verify ownership
|
||||
const existing = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
});
|
||||
async remove(id: string, userId: string) {
|
||||
// Verify ownership
|
||||
const existing = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Deck not found');
|
||||
}
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Deck not found');
|
||||
}
|
||||
|
||||
await this.db.delete(decks).where(eq(decks.id, id));
|
||||
await this.db.delete(decks).where(eq(decks.id, id));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async verifyOwnership(id: string, userId: string): Promise<boolean> {
|
||||
const deck = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
});
|
||||
return !!deck;
|
||||
}
|
||||
async verifyOwnership(id: string, userId: string): Promise<boolean> {
|
||||
const deck = await this.db.query.decks.findFirst({
|
||||
where: and(eq(decks.id, id), eq(decks.userId, userId)),
|
||||
});
|
||||
return !!deck;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { Controller, Get } from '@nestjs/common';
|
|||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'presi-backend',
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'presi-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -3,36 +3,36 @@ import { ValidationPipe } from '@nestjs/common';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5177',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001', // Mana Core Auth
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5177',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001', // Mana Core Auth
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT || 3008;
|
||||
await app.listen(port);
|
||||
console.log(`Presi backend running on http://localhost:${port}`);
|
||||
const port = process.env.PORT || 3008;
|
||||
await app.listen(port);
|
||||
console.log(`Presi backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -1,56 +1,42 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { ShareService } from './share.service';
|
||||
import { CreateShareDto } from './share.dto';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
@Controller('share')
|
||||
export class ShareController {
|
||||
constructor(private readonly shareService: ShareService) {}
|
||||
constructor(private readonly shareService: ShareService) {}
|
||||
|
||||
// Public endpoint - no auth required
|
||||
@Get(':code')
|
||||
async getSharedDeck(@Param('code') code: string) {
|
||||
return this.shareService.findByShareCode(code);
|
||||
}
|
||||
// Public endpoint - no auth required
|
||||
@Get(':code')
|
||||
async getSharedDeck(@Param('code') code: string) {
|
||||
return this.shareService.findByShareCode(code);
|
||||
}
|
||||
|
||||
// Authenticated endpoints
|
||||
@Post('deck/:deckId')
|
||||
@UseGuards(AuthGuard)
|
||||
async createShare(
|
||||
@Param('deckId') deckId: string,
|
||||
@Body() createShareDto: CreateShareDto,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
const expiresAt = createShareDto.expiresAt
|
||||
? new Date(createShareDto.expiresAt)
|
||||
: undefined;
|
||||
return this.shareService.createShare(deckId, req.user.sub, expiresAt);
|
||||
}
|
||||
// Authenticated endpoints
|
||||
@Post('deck/:deckId')
|
||||
@UseGuards(AuthGuard)
|
||||
async createShare(
|
||||
@Param('deckId') deckId: string,
|
||||
@Body() createShareDto: CreateShareDto,
|
||||
@Request() req: { user: { sub: string } }
|
||||
) {
|
||||
const expiresAt = createShareDto.expiresAt ? new Date(createShareDto.expiresAt) : undefined;
|
||||
return this.shareService.createShare(deckId, req.user.sub, expiresAt);
|
||||
}
|
||||
|
||||
@Get('deck/:deckId/links')
|
||||
@UseGuards(AuthGuard)
|
||||
async getSharesForDeck(
|
||||
@Param('deckId') deckId: string,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.shareService.getSharesForDeck(deckId, req.user.sub);
|
||||
}
|
||||
@Get('deck/:deckId/links')
|
||||
@UseGuards(AuthGuard)
|
||||
async getSharesForDeck(
|
||||
@Param('deckId') deckId: string,
|
||||
@Request() req: { user: { sub: string } }
|
||||
) {
|
||||
return this.shareService.getSharesForDeck(deckId, req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(':shareId')
|
||||
@UseGuards(AuthGuard)
|
||||
async deleteShare(
|
||||
@Param('shareId') shareId: string,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.shareService.deleteShare(shareId, req.user.sub);
|
||||
}
|
||||
@Delete(':shareId')
|
||||
@UseGuards(AuthGuard)
|
||||
async deleteShare(@Param('shareId') shareId: string, @Request() req: { user: { sub: string } }) {
|
||||
return this.shareService.deleteShare(shareId, req.user.sub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { IsOptional, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateShareDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import { ShareService } from './share.service';
|
|||
import { DeckModule } from '../deck/deck.module';
|
||||
|
||||
@Module({
|
||||
imports: [DeckModule],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
imports: [DeckModule],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { eq, and, gt, or, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
|
|
@ -13,104 +8,104 @@ import { randomBytes } from 'crypto';
|
|||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database,
|
||||
private readonly deckService: DeckService,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database,
|
||||
private readonly deckService: DeckService
|
||||
) {}
|
||||
|
||||
private generateShareCode(): string {
|
||||
return randomBytes(6).toString('hex'); // 12 character code
|
||||
}
|
||||
private generateShareCode(): string {
|
||||
return randomBytes(6).toString('hex'); // 12 character code
|
||||
}
|
||||
|
||||
async createShare(deckId: string, userId: string, expiresAt?: Date) {
|
||||
// Verify ownership
|
||||
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('You do not own this deck');
|
||||
}
|
||||
async createShare(deckId: string, userId: string, expiresAt?: Date) {
|
||||
// Verify ownership
|
||||
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('You do not own this deck');
|
||||
}
|
||||
|
||||
// Check if there's already a valid share
|
||||
const existingShare = await this.db.query.sharedDecks.findFirst({
|
||||
where: and(
|
||||
eq(sharedDecks.deckId, deckId),
|
||||
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date())),
|
||||
),
|
||||
});
|
||||
// Check if there's already a valid share
|
||||
const existingShare = await this.db.query.sharedDecks.findFirst({
|
||||
where: and(
|
||||
eq(sharedDecks.deckId, deckId),
|
||||
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
|
||||
),
|
||||
});
|
||||
|
||||
if (existingShare) {
|
||||
return existingShare;
|
||||
}
|
||||
if (existingShare) {
|
||||
return existingShare;
|
||||
}
|
||||
|
||||
// Create new share
|
||||
const [share] = await this.db
|
||||
.insert(sharedDecks)
|
||||
.values({
|
||||
deckId,
|
||||
shareCode: this.generateShareCode(),
|
||||
expiresAt: expiresAt || null,
|
||||
})
|
||||
.returning();
|
||||
// Create new share
|
||||
const [share] = await this.db
|
||||
.insert(sharedDecks)
|
||||
.values({
|
||||
deckId,
|
||||
shareCode: this.generateShareCode(),
|
||||
expiresAt: expiresAt || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return share;
|
||||
}
|
||||
return share;
|
||||
}
|
||||
|
||||
async findByShareCode(shareCode: string) {
|
||||
const share = await this.db.query.sharedDecks.findFirst({
|
||||
where: and(
|
||||
eq(sharedDecks.shareCode, shareCode),
|
||||
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date())),
|
||||
),
|
||||
with: {
|
||||
deck: {
|
||||
with: {
|
||||
slides: {
|
||||
orderBy: [slides.order],
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
async findByShareCode(shareCode: string) {
|
||||
const share = await this.db.query.sharedDecks.findFirst({
|
||||
where: and(
|
||||
eq(sharedDecks.shareCode, shareCode),
|
||||
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
|
||||
),
|
||||
with: {
|
||||
deck: {
|
||||
with: {
|
||||
slides: {
|
||||
orderBy: [slides.order],
|
||||
},
|
||||
theme: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
throw new NotFoundException('Shared deck not found or link has expired');
|
||||
}
|
||||
if (!share) {
|
||||
throw new NotFoundException('Shared deck not found or link has expired');
|
||||
}
|
||||
|
||||
return share.deck;
|
||||
}
|
||||
return share.deck;
|
||||
}
|
||||
|
||||
async getSharesForDeck(deckId: string, userId: string) {
|
||||
// Verify ownership
|
||||
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('You do not own this deck');
|
||||
}
|
||||
async getSharesForDeck(deckId: string, userId: string) {
|
||||
// Verify ownership
|
||||
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('You do not own this deck');
|
||||
}
|
||||
|
||||
return this.db.query.sharedDecks.findMany({
|
||||
where: eq(sharedDecks.deckId, deckId),
|
||||
});
|
||||
}
|
||||
return this.db.query.sharedDecks.findMany({
|
||||
where: eq(sharedDecks.deckId, deckId),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteShare(shareId: string, userId: string) {
|
||||
const share = await this.db.query.sharedDecks.findFirst({
|
||||
where: eq(sharedDecks.id, shareId),
|
||||
with: {
|
||||
deck: true,
|
||||
},
|
||||
});
|
||||
async deleteShare(shareId: string, userId: string) {
|
||||
const share = await this.db.query.sharedDecks.findFirst({
|
||||
where: eq(sharedDecks.id, shareId),
|
||||
with: {
|
||||
deck: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
if (!share) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
// Verify ownership of the deck
|
||||
if (share.deck.userId !== userId) {
|
||||
throw new ForbiddenException('You do not own this deck');
|
||||
}
|
||||
// Verify ownership of the deck
|
||||
if (share.deck.userId !== userId) {
|
||||
throw new ForbiddenException('You do not own this deck');
|
||||
}
|
||||
|
||||
await this.db.delete(sharedDecks).where(eq(sharedDecks.id, shareId));
|
||||
await this.db.delete(sharedDecks).where(eq(sharedDecks.id, shareId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { SlideService } from './slide.service';
|
||||
import { CreateSlideDto, UpdateSlideDto, ReorderSlidesDto } from './slide.dto';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
|
@ -15,39 +6,33 @@ import { AuthGuard } from '../auth/auth.guard';
|
|||
@Controller()
|
||||
@UseGuards(AuthGuard)
|
||||
export class SlideController {
|
||||
constructor(private readonly slideService: SlideService) {}
|
||||
constructor(private readonly slideService: SlideService) {}
|
||||
|
||||
@Post('decks/:deckId/slides')
|
||||
async create(
|
||||
@Param('deckId') deckId: string,
|
||||
@Body() createSlideDto: CreateSlideDto,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.slideService.create(deckId, req.user.sub, createSlideDto);
|
||||
}
|
||||
@Post('decks/:deckId/slides')
|
||||
async create(
|
||||
@Param('deckId') deckId: string,
|
||||
@Body() createSlideDto: CreateSlideDto,
|
||||
@Request() req: { user: { sub: string } }
|
||||
) {
|
||||
return this.slideService.create(deckId, req.user.sub, createSlideDto);
|
||||
}
|
||||
|
||||
@Put('slides/:id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateSlideDto: UpdateSlideDto,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.slideService.update(id, req.user.sub, updateSlideDto);
|
||||
}
|
||||
@Put('slides/:id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateSlideDto: UpdateSlideDto,
|
||||
@Request() req: { user: { sub: string } }
|
||||
) {
|
||||
return this.slideService.update(id, req.user.sub, updateSlideDto);
|
||||
}
|
||||
|
||||
@Delete('slides/:id')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.slideService.remove(id, req.user.sub);
|
||||
}
|
||||
@Delete('slides/:id')
|
||||
async remove(@Param('id') id: string, @Request() req: { user: { sub: string } }) {
|
||||
return this.slideService.remove(id, req.user.sub);
|
||||
}
|
||||
|
||||
@Put('slides/reorder')
|
||||
async reorder(
|
||||
@Body() reorderDto: ReorderSlidesDto,
|
||||
@Request() req: { user: { sub: string } },
|
||||
) {
|
||||
return this.slideService.reorder(req.user.sub, reorderDto);
|
||||
}
|
||||
@Put('slides/reorder')
|
||||
async reorder(@Body() reorderDto: ReorderSlidesDto, @Request() req: { user: { sub: string } }) {
|
||||
return this.slideService.reorder(req.user.sub, reorderDto);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,44 +2,44 @@ import { IsObject, IsOptional, IsNumber, IsArray, ValidateNested, IsUUID } from
|
|||
import { Type } from 'class-transformer';
|
||||
|
||||
class SlideContent {
|
||||
type: 'title' | 'content' | 'image' | 'split';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
bulletPoints?: string[];
|
||||
type: 'title' | 'content' | 'image' | 'split';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
bulletPoints?: string[];
|
||||
}
|
||||
|
||||
export class CreateSlideDto {
|
||||
@IsObject()
|
||||
content: SlideContent;
|
||||
@IsObject()
|
||||
content: SlideContent;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
order?: number;
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export class UpdateSlideDto {
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
content?: SlideContent;
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
content?: SlideContent;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
order?: number;
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
order?: number;
|
||||
}
|
||||
|
||||
class SlideOrderItem {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsNumber()
|
||||
order: number;
|
||||
@IsNumber()
|
||||
order: number;
|
||||
}
|
||||
|
||||
export class ReorderSlidesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SlideOrderItem)
|
||||
slides: SlideOrderItem[];
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SlideOrderItem)
|
||||
slides: SlideOrderItem[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { SlideService } from './slide.service';
|
|||
import { DeckModule } from '../deck/deck.module';
|
||||
|
||||
@Module({
|
||||
imports: [DeckModule],
|
||||
controllers: [SlideController],
|
||||
providers: [SlideService],
|
||||
imports: [DeckModule],
|
||||
controllers: [SlideController],
|
||||
providers: [SlideService],
|
||||
})
|
||||
export class SlideModule {}
|
||||
|
|
|
|||
|
|
@ -8,129 +8,117 @@ import { CreateSlideDto, UpdateSlideDto, ReorderSlidesDto } from './slide.dto';
|
|||
|
||||
@Injectable()
|
||||
export class SlideService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database,
|
||||
private readonly deckService: DeckService,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database,
|
||||
private readonly deckService: DeckService
|
||||
) {}
|
||||
|
||||
async create(deckId: string, userId: string, dto: CreateSlideDto) {
|
||||
// Verify deck ownership
|
||||
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('Not authorized to modify this deck');
|
||||
}
|
||||
async create(deckId: string, userId: string, dto: CreateSlideDto) {
|
||||
// Verify deck ownership
|
||||
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('Not authorized to modify this deck');
|
||||
}
|
||||
|
||||
// Get next order number
|
||||
const result = await this.db
|
||||
.select({ maxOrder: max(slides.order) })
|
||||
.from(slides)
|
||||
.where(eq(slides.deckId, deckId));
|
||||
// Get next order number
|
||||
const result = await this.db
|
||||
.select({ maxOrder: max(slides.order) })
|
||||
.from(slides)
|
||||
.where(eq(slides.deckId, deckId));
|
||||
|
||||
const nextOrder = dto.order ?? (result[0]?.maxOrder ?? -1) + 1;
|
||||
const nextOrder = dto.order ?? (result[0]?.maxOrder ?? -1) + 1;
|
||||
|
||||
const [slide] = await this.db
|
||||
.insert(slides)
|
||||
.values({
|
||||
deckId,
|
||||
order: nextOrder,
|
||||
content: dto.content,
|
||||
})
|
||||
.returning();
|
||||
const [slide] = await this.db
|
||||
.insert(slides)
|
||||
.values({
|
||||
deckId,
|
||||
order: nextOrder,
|
||||
content: dto.content,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Update deck's updatedAt
|
||||
await this.db
|
||||
.update(decks)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(decks.id, deckId));
|
||||
// Update deck's updatedAt
|
||||
await this.db.update(decks).set({ updatedAt: new Date() }).where(eq(decks.id, deckId));
|
||||
|
||||
return slide;
|
||||
}
|
||||
return slide;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateSlideDto) {
|
||||
// Get slide and verify ownership
|
||||
const slide = await this.db.query.slides.findFirst({
|
||||
where: eq(slides.id, id),
|
||||
with: { deck: true },
|
||||
});
|
||||
async update(id: string, userId: string, dto: UpdateSlideDto) {
|
||||
// Get slide and verify ownership
|
||||
const slide = await this.db.query.slides.findFirst({
|
||||
where: eq(slides.id, id),
|
||||
with: { deck: true },
|
||||
});
|
||||
|
||||
if (!slide) {
|
||||
throw new NotFoundException('Slide not found');
|
||||
}
|
||||
if (!slide) {
|
||||
throw new NotFoundException('Slide not found');
|
||||
}
|
||||
|
||||
if (slide.deck.userId !== userId) {
|
||||
throw new ForbiddenException('Not authorized to modify this slide');
|
||||
}
|
||||
if (slide.deck.userId !== userId) {
|
||||
throw new ForbiddenException('Not authorized to modify this slide');
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(slides)
|
||||
.set({
|
||||
content: dto.content ?? slide.content,
|
||||
order: dto.order ?? slide.order,
|
||||
})
|
||||
.where(eq(slides.id, id))
|
||||
.returning();
|
||||
const [updated] = await this.db
|
||||
.update(slides)
|
||||
.set({
|
||||
content: dto.content ?? slide.content,
|
||||
order: dto.order ?? slide.order,
|
||||
})
|
||||
.where(eq(slides.id, id))
|
||||
.returning();
|
||||
|
||||
// Update deck's updatedAt
|
||||
await this.db
|
||||
.update(decks)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(decks.id, slide.deckId));
|
||||
// Update deck's updatedAt
|
||||
await this.db.update(decks).set({ updatedAt: new Date() }).where(eq(decks.id, slide.deckId));
|
||||
|
||||
return updated;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string) {
|
||||
// Get slide and verify ownership
|
||||
const slide = await this.db.query.slides.findFirst({
|
||||
where: eq(slides.id, id),
|
||||
with: { deck: true },
|
||||
});
|
||||
async remove(id: string, userId: string) {
|
||||
// Get slide and verify ownership
|
||||
const slide = await this.db.query.slides.findFirst({
|
||||
where: eq(slides.id, id),
|
||||
with: { deck: true },
|
||||
});
|
||||
|
||||
if (!slide) {
|
||||
throw new NotFoundException('Slide not found');
|
||||
}
|
||||
if (!slide) {
|
||||
throw new NotFoundException('Slide not found');
|
||||
}
|
||||
|
||||
if (slide.deck.userId !== userId) {
|
||||
throw new ForbiddenException('Not authorized to delete this slide');
|
||||
}
|
||||
if (slide.deck.userId !== userId) {
|
||||
throw new ForbiddenException('Not authorized to delete this slide');
|
||||
}
|
||||
|
||||
await this.db.delete(slides).where(eq(slides.id, id));
|
||||
await this.db.delete(slides).where(eq(slides.id, id));
|
||||
|
||||
// Update deck's updatedAt
|
||||
await this.db
|
||||
.update(decks)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(decks.id, slide.deckId));
|
||||
// Update deck's updatedAt
|
||||
await this.db.update(decks).set({ updatedAt: new Date() }).where(eq(decks.id, slide.deckId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async reorder(userId: string, dto: ReorderSlidesDto) {
|
||||
// Verify ownership of all slides
|
||||
for (const item of dto.slides) {
|
||||
const slide = await this.db.query.slides.findFirst({
|
||||
where: eq(slides.id, item.id),
|
||||
with: { deck: true },
|
||||
});
|
||||
async reorder(userId: string, dto: ReorderSlidesDto) {
|
||||
// Verify ownership of all slides
|
||||
for (const item of dto.slides) {
|
||||
const slide = await this.db.query.slides.findFirst({
|
||||
where: eq(slides.id, item.id),
|
||||
with: { deck: true },
|
||||
});
|
||||
|
||||
if (!slide) {
|
||||
throw new NotFoundException(`Slide ${item.id} not found`);
|
||||
}
|
||||
if (!slide) {
|
||||
throw new NotFoundException(`Slide ${item.id} not found`);
|
||||
}
|
||||
|
||||
if (slide.deck.userId !== userId) {
|
||||
throw new ForbiddenException('Not authorized to reorder these slides');
|
||||
}
|
||||
}
|
||||
if (slide.deck.userId !== userId) {
|
||||
throw new ForbiddenException('Not authorized to reorder these slides');
|
||||
}
|
||||
}
|
||||
|
||||
// Update orders
|
||||
for (const item of dto.slides) {
|
||||
await this.db
|
||||
.update(slides)
|
||||
.set({ order: item.order })
|
||||
.where(eq(slides.id, item.id));
|
||||
}
|
||||
// Update orders
|
||||
for (const item of dto.slides) {
|
||||
await this.db.update(slides).set({ order: item.order }).where(eq(slides.id, item.id));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@ import { ThemeService } from './theme.service';
|
|||
|
||||
@Controller('themes')
|
||||
export class ThemeController {
|
||||
constructor(private readonly themeService: ThemeService) {}
|
||||
constructor(private readonly themeService: ThemeService) {}
|
||||
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.themeService.findAll();
|
||||
}
|
||||
@Get()
|
||||
async findAll() {
|
||||
return this.themeService.findAll();
|
||||
}
|
||||
|
||||
@Get('default')
|
||||
async findDefault() {
|
||||
return this.themeService.findDefault();
|
||||
}
|
||||
@Get('default')
|
||||
async findDefault() {
|
||||
return this.themeService.findDefault();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.themeService.findOne(id);
|
||||
}
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.themeService.findOne(id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { ThemeController } from './theme.controller';
|
|||
import { ThemeService } from './theme.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ThemeController],
|
||||
providers: [ThemeService],
|
||||
exports: [ThemeService],
|
||||
controllers: [ThemeController],
|
||||
providers: [ThemeService],
|
||||
exports: [ThemeService],
|
||||
})
|
||||
export class ThemeModule {}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,22 @@ import { themes } from '../db/schema';
|
|||
|
||||
@Injectable()
|
||||
export class ThemeService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: Database
|
||||
) {}
|
||||
|
||||
async findAll() {
|
||||
return this.db.select().from(themes);
|
||||
}
|
||||
async findAll() {
|
||||
return this.db.select().from(themes);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const result = await this.db.select().from(themes).where(eq(themes.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
async findOne(id: string) {
|
||||
const result = await this.db.select().from(themes).where(eq(themes.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findDefault() {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(themes)
|
||||
.where(eq(themes.isDefault, true));
|
||||
return result[0] || null;
|
||||
}
|
||||
async findDefault() {
|
||||
const result = await this.db.select().from(themes).where(eq(themes.isDefault, true));
|
||||
return result[0] || null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,49 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "presi",
|
||||
"slug": "presi",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "83d42377-9b68-4b82-a049-565584d893ba"
|
||||
}
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "presi",
|
||||
"slug": "presi",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "83d42377-9b68-4b82-a049-565584d893ba"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
title: 'Login',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{
|
||||
title: 'Register',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="forgot-password"
|
||||
options={{
|
||||
title: 'Reset Password',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
title: 'Login',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{
|
||||
title: 'Register',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="forgot-password"
|
||||
options={{
|
||||
title: 'Reset Password',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,167 +1,157 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
import { resetPassword } from '../../services/auth';
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
Alert.alert('Error', 'Please enter your email address');
|
||||
return;
|
||||
}
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
Alert.alert('Error', 'Please enter your email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await resetPassword(email);
|
||||
setResetSent(true);
|
||||
} catch (error: any) {
|
||||
console.error('Password reset error:', error);
|
||||
Alert.alert(
|
||||
'Reset Failed',
|
||||
error.message || 'An error occurred while sending reset email'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await resetPassword(email);
|
||||
setResetSent(true);
|
||||
} catch (error: any) {
|
||||
console.error('Password reset error:', error);
|
||||
Alert.alert('Reset Failed', error.message || 'An error occurred while sending reset email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (resetSent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Check Your Email</Text>
|
||||
<Text style={styles.message}>
|
||||
We've sent password reset instructions to {email}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={() => router.replace('/login')}
|
||||
>
|
||||
<Text style={styles.buttonText}>Return to Login</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (resetSent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Check Your Email</Text>
|
||||
<Text style={styles.message}>We've sent password reset instructions to {email}</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={() => router.replace('/login')}>
|
||||
<Text style={styles.buttonText}>Return to Login</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Reset Password</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Enter your email to receive reset instructions
|
||||
</Text>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Reset Password</Text>
|
||||
<Text style={styles.subtitle}>Enter your email to receive reset instructions</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Instructions'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Instructions'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Link href="/login" style={styles.link}>
|
||||
Back to Login
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<View style={styles.links}>
|
||||
<Link href="/login" style={styles.link}>
|
||||
Back to Login
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,152 +1,147 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useRouter, Link } from 'expo-router';
|
||||
import { loginUser } from '../../services/auth';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginUser(email, password);
|
||||
router.replace('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
Alert.alert(
|
||||
'Login Failed',
|
||||
error.message || 'Please check your credentials and try again'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginUser(email, password);
|
||||
router.replace('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
Alert.alert('Login Failed', error.message || 'Please check your credentials and try again');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to continue</Text>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to continue</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>{isLoading ? 'Signing in...' : 'Sign In'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Link href="/forgot-password" style={styles.link}>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
<Link href="/register" style={styles.link}>
|
||||
Create Account
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<View style={styles.links}>
|
||||
<Link href="/forgot-password" style={styles.link}>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
<Link href="/register" style={styles.link}>
|
||||
Create Account
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 24,
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 24,
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,168 +1,165 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useRouter, Link } from 'expo-router';
|
||||
import { registerUser } from '../../services/auth';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Error', 'Password should be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Error', 'Password should be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await registerUser(email, password);
|
||||
router.replace('/');
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
Alert.alert(
|
||||
'Registration Failed',
|
||||
error.message || 'An error occurred during registration'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await registerUser(email, password);
|
||||
router.replace('/');
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
Alert.alert('Registration Failed', error.message || 'An error occurred during registration');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Sign up to get started</Text>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Sign up to get started</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password-new"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password-new"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password-new"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password-new"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Creating Account...' : 'Create Account'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Creating Account...' : 'Create Account'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Link href="/login" style={styles.link}>
|
||||
Already have an account? Sign In
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<View style={styles.links}>
|
||||
<Link href="/login" style={styles.link}>
|
||||
Already have an account? Sign In
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,123 +8,117 @@ import { ThemeProvider, useTheme } from '../components/ThemeProvider';
|
|||
import { Header } from '../components/Menu/Header';
|
||||
|
||||
function StackNavigator() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
header: ({ route, options }) => {
|
||||
let title = options.title || '';
|
||||
let showAddDeck = false;
|
||||
let rightContent = options.headerRight?.({});
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
header: ({ route, options }) => {
|
||||
let title = options.title || '';
|
||||
let showAddDeck = false;
|
||||
let rightContent = options.headerRight?.({});
|
||||
|
||||
if (route.name === 'index') {
|
||||
title = `My Decks (${route.params?.deckCount || 0})`;
|
||||
showAddDeck = true;
|
||||
}
|
||||
if (route.name === 'index') {
|
||||
title = `My Decks (${route.params?.deckCount || 0})`;
|
||||
showAddDeck = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Header
|
||||
title={title}
|
||||
showAddDeck={showAddDeck}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'My Decks',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="decks/[id]"
|
||||
options={{
|
||||
title: 'Deck Details',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="deck/[id]"
|
||||
options={{
|
||||
headerShown: false
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="create-deck"
|
||||
options={{
|
||||
title: 'Create New Deck',
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/login"
|
||||
options={{
|
||||
title: 'Login',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/register"
|
||||
options={{
|
||||
title: 'Register',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
);
|
||||
return <Header title={title} showAddDeck={showAddDeck} rightContent={rightContent} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'My Decks',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="decks/[id]"
|
||||
options={{
|
||||
title: 'Deck Details',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="deck/[id]"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="create-deck"
|
||||
options={{
|
||||
title: 'Create New Deck',
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/login"
|
||||
options={{
|
||||
title: 'Login',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/register"
|
||||
options={{
|
||||
title: 'Register',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutContent() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<StackNavigator />
|
||||
</View>
|
||||
);
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<StackNavigator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChange(user => {
|
||||
if (!user && !segments.includes('(auth)')) {
|
||||
router.replace('/login');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChange((user) => {
|
||||
if (!user && !segments.includes('(auth)')) {
|
||||
router.replace('/login');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RootLayoutContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RootLayoutContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { View, TouchableOpacity, Text, Modal, SafeAreaView, Alert, Platform, ScrollView } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
Modal,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { getDeckSlides, getDeck, deleteSlide, reorderSlide } from '../../services/firestore';
|
||||
import { Slide, Deck } from '../../types/models';
|
||||
|
|
@ -11,312 +20,379 @@ import { useTheme } from '../../components/ThemeProvider';
|
|||
import { Header } from '../../components/Menu/Header';
|
||||
|
||||
export default function DeckScreen() {
|
||||
const { theme } = useTheme();
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const [slides, setSlides] = useState<Slide[]>([]);
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [editingSlide, setEditingSlide] = useState<Slide | null>(null);
|
||||
const [isPresentationMode, setIsPresentationMode] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const [slides, setSlides] = useState<Slide[]>([]);
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [editingSlide, setEditingSlide] = useState<Slide | null>(null);
|
||||
const [isPresentationMode, setIsPresentationMode] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
|
||||
const loadDeckAndSlides = async () => {
|
||||
try {
|
||||
const [deckData, deckSlides] = await Promise.all([
|
||||
getDeck(id as string),
|
||||
getDeckSlides(id as string)
|
||||
]);
|
||||
console.log('[DeckScreen] Loaded slides:', deckSlides.map(s => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
updatedAt: s.updatedAt
|
||||
})));
|
||||
setDeck(deckData);
|
||||
const sortedSlides = deckSlides.sort((a, b) => a.order - b.order);
|
||||
console.log('[DeckScreen] Sorted slides:', sortedSlides.map(s => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
updatedAt: s.updatedAt
|
||||
})));
|
||||
setSlides(sortedSlides);
|
||||
} catch (error) {
|
||||
console.error('Error loading deck data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const loadDeckAndSlides = async () => {
|
||||
try {
|
||||
const [deckData, deckSlides] = await Promise.all([
|
||||
getDeck(id as string),
|
||||
getDeckSlides(id as string),
|
||||
]);
|
||||
console.log(
|
||||
'[DeckScreen] Loaded slides:',
|
||||
deckSlides.map((s) => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
updatedAt: s.updatedAt,
|
||||
}))
|
||||
);
|
||||
setDeck(deckData);
|
||||
const sortedSlides = deckSlides.sort((a, b) => a.order - b.order);
|
||||
console.log(
|
||||
'[DeckScreen] Sorted slides:',
|
||||
sortedSlides.map((s) => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
updatedAt: s.updatedAt,
|
||||
}))
|
||||
);
|
||||
setSlides(sortedSlides);
|
||||
} catch (error) {
|
||||
console.error('Error loading deck data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPresentation = useCallback(() => {
|
||||
if (slides.length > 0) {
|
||||
setIsPresentationMode(true);
|
||||
}
|
||||
}, [slides.length]);
|
||||
const handleStartPresentation = useCallback(() => {
|
||||
if (slides.length > 0) {
|
||||
setIsPresentationMode(true);
|
||||
}
|
||||
}, [slides.length]);
|
||||
|
||||
const handleDeckDelete = useCallback(() => {
|
||||
setIsDeleteModalVisible(true);
|
||||
}, []);
|
||||
const handleDeckDelete = useCallback(() => {
|
||||
setIsDeleteModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEditSlide = useCallback((slide: Slide) => {
|
||||
setEditingSlide(slide);
|
||||
setIsCreateModalVisible(true);
|
||||
}, []);
|
||||
const handleEditSlide = useCallback((slide: Slide) => {
|
||||
setEditingSlide(slide);
|
||||
setIsCreateModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteDeck = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// await deleteDeck(id as string, setId as string);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error deleting deck:', error);
|
||||
// setError('Failed to delete deck');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsDeleteModalVisible(false);
|
||||
}
|
||||
};
|
||||
const handleDeleteDeck = async () => {
|
||||
if (!id) return;
|
||||
|
||||
const handleDeleteSlide = useCallback(async (slide: Slide) => {
|
||||
try {
|
||||
await deleteSlide(slide.id, id as string);
|
||||
await loadDeckAndSlides();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error deleting slide:', error);
|
||||
Alert.alert('Error', 'Failed to delete slide');
|
||||
}
|
||||
}, [loadDeckAndSlides, id]);
|
||||
try {
|
||||
setLoading(true);
|
||||
// await deleteDeck(id as string, setId as string);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error deleting deck:', error);
|
||||
// setError('Failed to delete deck');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsDeleteModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveSlide = useCallback(async (slide: Slide, direction: 'up' | 'down') => {
|
||||
console.log('[DeckScreen] Starting handleMoveSlide:', {
|
||||
slideId: slide.id.substring(0, 4),
|
||||
direction,
|
||||
currentOrder: slide.order
|
||||
});
|
||||
const currentIndex = slides.findIndex(s => s.id === slide.id);
|
||||
console.log('[DeckScreen] Current slide index:', currentIndex, 'Total slides:', slides.length);
|
||||
console.log('[DeckScreen] All slides:', slides.map(s => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order
|
||||
})));
|
||||
if (currentIndex === -1) {
|
||||
console.log('[DeckScreen] Slide not found in array');
|
||||
return;
|
||||
}
|
||||
const handleDeleteSlide = useCallback(
|
||||
async (slide: Slide) => {
|
||||
try {
|
||||
await deleteSlide(slide.id, id as string);
|
||||
await loadDeckAndSlides();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error deleting slide:', error);
|
||||
Alert.alert('Error', 'Failed to delete slide');
|
||||
}
|
||||
},
|
||||
[loadDeckAndSlides, id]
|
||||
);
|
||||
|
||||
// Normalize all orders to be integers between 1 and slides.length
|
||||
const normalizedSlides = [...slides].sort((a, b) => a.order - b.order);
|
||||
const normalizedOrders = new Map(
|
||||
normalizedSlides.map((s, i) => [s.id, i + 1])
|
||||
);
|
||||
|
||||
let newOrder;
|
||||
if (direction === 'up' && currentIndex > 0) {
|
||||
// Moving up: use the previous slide's normalized order
|
||||
const prevOrder = normalizedOrders.get(slides[currentIndex - 1].id) || 1;
|
||||
const currOrder = normalizedOrders.get(slide.id) || 2;
|
||||
newOrder = prevOrder + (currOrder - prevOrder) / 2;
|
||||
|
||||
console.log('[DeckScreen] Moving up - New order:', {
|
||||
newOrder,
|
||||
previousSlideId: slides[currentIndex - 1].id.substring(0, 4),
|
||||
previousOrder: prevOrder,
|
||||
currentSlideId: slides[currentIndex].id.substring(0, 4),
|
||||
currentOrder: currOrder
|
||||
});
|
||||
} else if (direction === 'down' && currentIndex < slides.length - 1) {
|
||||
// Moving down: use the next slide's normalized order
|
||||
const currOrder = normalizedOrders.get(slide.id) || 1;
|
||||
const nextOrder = normalizedOrders.get(slides[currentIndex + 1].id) || 2;
|
||||
newOrder = currOrder + (nextOrder - currOrder) / 2;
|
||||
|
||||
console.log('[DeckScreen] Moving down - New order:', {
|
||||
newOrder,
|
||||
nextSlideId: slides[currentIndex + 1].id.substring(0, 4),
|
||||
nextOrder: nextOrder,
|
||||
currentSlideId: slides[currentIndex].id.substring(0, 4),
|
||||
currentOrder: currOrder
|
||||
});
|
||||
} else {
|
||||
console.log('[DeckScreen] Cannot move slide:', {
|
||||
direction,
|
||||
currentIndex,
|
||||
slidesLength: slides.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handleMoveSlide = useCallback(
|
||||
async (slide: Slide, direction: 'up' | 'down') => {
|
||||
console.log('[DeckScreen] Starting handleMoveSlide:', {
|
||||
slideId: slide.id.substring(0, 4),
|
||||
direction,
|
||||
currentOrder: slide.order,
|
||||
});
|
||||
const currentIndex = slides.findIndex((s) => s.id === slide.id);
|
||||
console.log(
|
||||
'[DeckScreen] Current slide index:',
|
||||
currentIndex,
|
||||
'Total slides:',
|
||||
slides.length
|
||||
);
|
||||
console.log(
|
||||
'[DeckScreen] All slides:',
|
||||
slides.map((s) => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
}))
|
||||
);
|
||||
if (currentIndex === -1) {
|
||||
console.log('[DeckScreen] Slide not found in array');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[DeckScreen] Calling reorderSlide with:', {
|
||||
slideId: slide.id.substring(0, 4),
|
||||
newOrder,
|
||||
deckId: id
|
||||
});
|
||||
await reorderSlide(slide.id, newOrder, id as string);
|
||||
console.log('[DeckScreen] Reorder successful, reloading slides');
|
||||
await loadDeckAndSlides();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error moving slide:', error);
|
||||
Alert.alert('Error', 'Failed to move slide');
|
||||
}
|
||||
}, [slides, loadDeckAndSlides, id]);
|
||||
// Normalize all orders to be integers between 1 and slides.length
|
||||
const normalizedSlides = [...slides].sort((a, b) => a.order - b.order);
|
||||
const normalizedOrders = new Map(normalizedSlides.map((s, i) => [s.id, i + 1]));
|
||||
|
||||
useEffect(() => {
|
||||
loadDeckAndSlides();
|
||||
}, [id]);
|
||||
let newOrder;
|
||||
if (direction === 'up' && currentIndex > 0) {
|
||||
// Moving up: use the previous slide's normalized order
|
||||
const prevOrder = normalizedOrders.get(slides[currentIndex - 1].id) || 1;
|
||||
const currOrder = normalizedOrders.get(slide.id) || 2;
|
||||
newOrder = prevOrder + (currOrder - prevOrder) / 2;
|
||||
|
||||
useEffect(() => {
|
||||
if (deck) {
|
||||
router.setParams({
|
||||
deckName: deck.name,
|
||||
slideCount: slides.length,
|
||||
onStartPresentation: handleStartPresentation,
|
||||
onDeleteDeck: handleDeckDelete
|
||||
});
|
||||
}
|
||||
}, [deck, slides, handleStartPresentation, handleDeckDelete]);
|
||||
console.log('[DeckScreen] Moving up - New order:', {
|
||||
newOrder,
|
||||
previousSlideId: slides[currentIndex - 1].id.substring(0, 4),
|
||||
previousOrder: prevOrder,
|
||||
currentSlideId: slides[currentIndex].id.substring(0, 4),
|
||||
currentOrder: currOrder,
|
||||
});
|
||||
} else if (direction === 'down' && currentIndex < slides.length - 1) {
|
||||
// Moving down: use the next slide's normalized order
|
||||
const currOrder = normalizedOrders.get(slide.id) || 1;
|
||||
const nextOrder = normalizedOrders.get(slides[currentIndex + 1].id) || 2;
|
||||
newOrder = currOrder + (nextOrder - currOrder) / 2;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'transparent' }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: '500', color: theme.colors.textPrimary }}>Loading slides...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
console.log('[DeckScreen] Moving down - New order:', {
|
||||
newOrder,
|
||||
nextSlideId: slides[currentIndex + 1].id.substring(0, 4),
|
||||
nextOrder: nextOrder,
|
||||
currentSlideId: slides[currentIndex].id.substring(0, 4),
|
||||
currentOrder: currOrder,
|
||||
});
|
||||
} else {
|
||||
console.log('[DeckScreen] Cannot move slide:', {
|
||||
direction,
|
||||
currentIndex,
|
||||
slidesLength: slides.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: 'transparent' }}>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SlideList
|
||||
slides={slides}
|
||||
onEditSlide={handleEditSlide}
|
||||
onDeleteSlide={handleDeleteSlide}
|
||||
onMoveSlide={handleMoveSlide}
|
||||
/>
|
||||
<Header
|
||||
title={deck?.name || 'Loading...'}
|
||||
showPresent={true}
|
||||
onPresentPress={handleStartPresentation}
|
||||
disabled={!slides.length}
|
||||
slideCount={slides.length}
|
||||
position="bottom"
|
||||
/>
|
||||
</View>
|
||||
<Modal
|
||||
visible={isCreateModalVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: `${theme.colors.backgroundPrimary}CC` }}>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
margin: 16,
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
width: '100%'
|
||||
}}>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderPrimary
|
||||
}}>
|
||||
<Text style={{ fontSize: 20, fontWeight: '600', color: theme.colors.textPrimary }}>
|
||||
{editingSlide ? 'Edit Slide' : 'Create New Slide'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={{ padding: 8, borderRadius: 8, backgroundColor: theme.colors.backgroundSecondary }}
|
||||
onPress={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<SlideEditor
|
||||
deckId={id as string}
|
||||
slide={editingSlide}
|
||||
onSuccess={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
loadDeckAndSlides();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
try {
|
||||
console.log('[DeckScreen] Calling reorderSlide with:', {
|
||||
slideId: slide.id.substring(0, 4),
|
||||
newOrder,
|
||||
deckId: id,
|
||||
});
|
||||
await reorderSlide(slide.id, newOrder, id as string);
|
||||
console.log('[DeckScreen] Reorder successful, reloading slides');
|
||||
await loadDeckAndSlides();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error moving slide:', error);
|
||||
Alert.alert('Error', 'Failed to move slide');
|
||||
}
|
||||
},
|
||||
[slides, loadDeckAndSlides, id]
|
||||
);
|
||||
|
||||
<Modal
|
||||
visible={isDeleteModalVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={() => setIsDeleteModalVisible(false)}
|
||||
>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: `${theme.colors.backgroundPrimary}CC` }}>
|
||||
<View style={{ width: '90%', maxWidth: 600, borderRadius: 12, padding: 20, backgroundColor: theme.colors.backgroundPrimary }}>
|
||||
<Text style={{ fontSize: 20, fontWeight: '600', color: theme.colors.textPrimary }}>
|
||||
Delete Slide
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, marginBottom: 20, color: theme.colors.textSecondary }}>
|
||||
Are you sure you want to delete this slide? This action cannot be undone.
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 20 }}>
|
||||
<TouchableOpacity
|
||||
style={{ paddingVertical: 8, paddingHorizontal: 16, borderRadius: 8, backgroundColor: theme.colors.backgroundSecondary }}
|
||||
onPress={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: '500', color: theme.colors.textPrimary }}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{ paddingVertical: 8, paddingHorizontal: 16, borderRadius: 8, backgroundColor: theme.colors.error }}
|
||||
onPress={handleDeleteSlide}
|
||||
>
|
||||
<Text style={{ fontSize: 16, fontWeight: '500', color: theme.colors.textOnPrimary }}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
useEffect(() => {
|
||||
loadDeckAndSlides();
|
||||
}, [id]);
|
||||
|
||||
<Modal
|
||||
visible={isPresentationMode}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={() => setIsPresentationMode(false)}
|
||||
statusBarTranslucent={true}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<PresentationMode
|
||||
slides={slides}
|
||||
onClose={() => setIsPresentationMode(false)}
|
||||
/>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (deck) {
|
||||
router.setParams({
|
||||
deckName: deck.name,
|
||||
slideCount: slides.length,
|
||||
onStartPresentation: handleStartPresentation,
|
||||
onDeleteDeck: handleDeckDelete,
|
||||
});
|
||||
}
|
||||
}, [deck, slides, handleStartPresentation, handleDeckDelete]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: '500', color: theme.colors.textPrimary }}>
|
||||
Loading slides...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: 'transparent' }}>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SlideList
|
||||
slides={slides}
|
||||
onEditSlide={handleEditSlide}
|
||||
onDeleteSlide={handleDeleteSlide}
|
||||
onMoveSlide={handleMoveSlide}
|
||||
/>
|
||||
<Header
|
||||
title={deck?.name || 'Loading...'}
|
||||
showPresent={true}
|
||||
onPresentPress={handleStartPresentation}
|
||||
disabled={!slides.length}
|
||||
slideCount={slides.length}
|
||||
position="bottom"
|
||||
/>
|
||||
</View>
|
||||
<Modal
|
||||
visible={isCreateModalVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: `${theme.colors.backgroundPrimary}CC` }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: 16,
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, fontWeight: '600', color: theme.colors.textPrimary }}>
|
||||
{editingSlide ? 'Edit Slide' : 'Create New Slide'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
}}
|
||||
onPress={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<SlideEditor
|
||||
deckId={id as string}
|
||||
slide={editingSlide}
|
||||
onSuccess={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
loadDeckAndSlides();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={isDeleteModalVisible}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={() => setIsDeleteModalVisible(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, fontWeight: '600', color: theme.colors.textPrimary }}>
|
||||
Delete Slide
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, marginBottom: 20, color: theme.colors.textSecondary }}>
|
||||
Are you sure you want to delete this slide? This action cannot be undone.
|
||||
</Text>
|
||||
<View
|
||||
style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 20 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
}}
|
||||
onPress={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: '500', color: theme.colors.textPrimary }}
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.error,
|
||||
}}
|
||||
onPress={handleDeleteSlide}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: '500', color: theme.colors.textOnPrimary }}
|
||||
>
|
||||
Delete
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={isPresentationMode}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={() => setIsPresentationMode(false)}
|
||||
statusBarTranslucent={true}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<PresentationMode slides={slides} onClose={() => setIsPresentationMode(false)} />
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Platform,
|
||||
Image
|
||||
} from 'react-native';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, Image } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { getUserDecks, deleteDeck, getDeckSlides, migrateDecksToNewSchema } from '../services/firestore';
|
||||
import {
|
||||
getUserDecks,
|
||||
deleteDeck,
|
||||
getDeckSlides,
|
||||
migrateDecksToNewSchema,
|
||||
} from '../services/firestore';
|
||||
import { Deck, Slide } from '../types/models';
|
||||
import { DeckList } from '../components/decks/DeckList';
|
||||
import { CreateDeckForm } from '../components/forms/CreateDeckForm';
|
||||
|
|
@ -25,388 +22,396 @@ import { ThemeVariant, THEME_PATTERNS, THEME_NAMES } from '../constants/theme';
|
|||
import { CreateItemButton } from '../components/common/CreateItemButton';
|
||||
|
||||
function App() {
|
||||
const router = useRouter();
|
||||
const { theme, themeVariant, colorMode } = useTheme();
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
const [isShareModalVisible, setIsShareModalVisible] = useState(false);
|
||||
const [deckToDelete, setDeckToDelete] = useState<Deck | null>(null);
|
||||
const [deckToShare, setDeckToShare] = useState<Deck | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decksWithFirstSlide, setDecksWithFirstSlide] = useState<{ [key: string]: string }>({});
|
||||
const [slideCounts, setSlideCounts] = useState<{ [key: string]: number }>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { theme, themeVariant, colorMode } = useTheme();
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
const [isShareModalVisible, setIsShareModalVisible] = useState(false);
|
||||
const [deckToDelete, setDeckToDelete] = useState<Deck | null>(null);
|
||||
const [deckToShare, setDeckToShare] = useState<Deck | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decksWithFirstSlide, setDecksWithFirstSlide] = useState<{ [key: string]: string }>({});
|
||||
const [slideCounts, setSlideCounts] = useState<{ [key: string]: number }>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Listen for the create deck modal event
|
||||
const handleOpenCreateModal = () => {
|
||||
setIsCreateModalVisible(true);
|
||||
};
|
||||
window.addEventListener('openCreateDeckModal', handleOpenCreateModal);
|
||||
|
||||
return () => {
|
||||
setMounted(false);
|
||||
window.removeEventListener('openCreateDeckModal', handleOpenCreateModal);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && decks) {
|
||||
router.setParams({ deckCount: decks.length });
|
||||
}
|
||||
}, [decks, mounted]);
|
||||
// Listen for the create deck modal event
|
||||
const handleOpenCreateModal = () => {
|
||||
setIsCreateModalVisible(true);
|
||||
};
|
||||
window.addEventListener('openCreateDeckModal', handleOpenCreateModal);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[App] Setting up auth state listener');
|
||||
let mounted = true;
|
||||
|
||||
const loadDecksForUser = async (currentUser: User) => {
|
||||
if (!currentUser || !mounted) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Migrate existing decks to new schema
|
||||
await migrateDecksToNewSchema(currentUser.uid);
|
||||
|
||||
const userDecks = await getUserDecks(currentUser.uid);
|
||||
if (mounted) {
|
||||
setDecks(userDecks);
|
||||
|
||||
// Load first slide for each deck
|
||||
const firstSlides: { [key: string]: string } = {};
|
||||
const counts: { [key: string]: number } = {};
|
||||
|
||||
for (const deck of userDecks) {
|
||||
try {
|
||||
const slides = await getDeckSlides(deck.id);
|
||||
if (slides.length > 0) {
|
||||
firstSlides[deck.id] = slides[0].imageUrl || '';
|
||||
}
|
||||
counts[deck.id] = slides.length;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting slides:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setDecksWithFirstSlide(firstSlides);
|
||||
setSlideCounts(counts);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Error loading decks:', error);
|
||||
if (mounted) {
|
||||
setError('Failed to load decks');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
setMounted(false);
|
||||
window.removeEventListener('openCreateDeckModal', handleOpenCreateModal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||
console.log('[App] Auth state changed:', currentUser?.email);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (currentUser) {
|
||||
setUser(currentUser);
|
||||
await loadDecksForUser(currentUser);
|
||||
} else {
|
||||
setUser(null);
|
||||
setDecks([]);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
if (mounted && decks) {
|
||||
router.setParams({ deckCount: decks.length });
|
||||
}
|
||||
}, [decks, mounted]);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
console.log('[App] Setting up auth state listener');
|
||||
let mounted = true;
|
||||
|
||||
const handleDeckPress = (deck: Deck) => {
|
||||
router.push(`/deck/${deck.id}`);
|
||||
};
|
||||
const loadDecksForUser = async (currentUser: User) => {
|
||||
if (!currentUser || !mounted) return;
|
||||
|
||||
const handleDeckDelete = (deck: Deck) => {
|
||||
setDeckToDelete(deck);
|
||||
setIsDeleteModalVisible(true);
|
||||
};
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const handleShareDeck = (deck: Deck) => {
|
||||
setDeckToShare(deck);
|
||||
setIsShareModalVisible(true);
|
||||
};
|
||||
// Migrate existing decks to new schema
|
||||
await migrateDecksToNewSchema(currentUser.uid);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deckToDelete) return;
|
||||
const userDecks = await getUserDecks(currentUser.uid);
|
||||
if (mounted) {
|
||||
setDecks(userDecks);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteDeck(deckToDelete.id);
|
||||
const updatedDecks = decks.filter(deck => deck.id !== deckToDelete.id);
|
||||
setDecks(updatedDecks);
|
||||
} catch (error) {
|
||||
console.error('[App] Error deleting deck:', error);
|
||||
setError('Failed to delete deck');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}
|
||||
};
|
||||
// Load first slide for each deck
|
||||
const firstSlides: { [key: string]: string } = {};
|
||||
const counts: { [key: string]: number } = {};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={styles.content}>
|
||||
<DeckList
|
||||
decks={decks}
|
||||
onDeckPress={(deck) => {
|
||||
router.push(`/deck/${deck.id}`);
|
||||
}}
|
||||
onCreateDeck={() => setIsCreateModalVisible(true)}
|
||||
onDeleteDeck={(deck) => {
|
||||
setDeckToDelete(deck);
|
||||
setIsDeleteModalVisible(true);
|
||||
}}
|
||||
onShareDeck={(deck) => {
|
||||
setDeckToShare(deck);
|
||||
setIsShareModalVisible(true);
|
||||
}}
|
||||
firstSlideImages={decksWithFirstSlide}
|
||||
loading={loading}
|
||||
slideCounts={slideCounts}
|
||||
onProfilePress={() => router.push('/profile')}
|
||||
onSettingsPress={() => router.push('/settings')}
|
||||
scrollPadding={{
|
||||
vertical: { top: 20, bottom: 80 },
|
||||
horizontal: { top: 200, bottom: 200 }
|
||||
}}
|
||||
deckSpacing={{ vertical: 24, horizontal: 40 }}
|
||||
headerRight={() => (
|
||||
<View style={{ flexDirection: 'row', gap: 15, marginRight: 15 }}>
|
||||
<CreateItemButton
|
||||
onPress={() => setIsCreateModalVisible(true)}
|
||||
variant="button"
|
||||
title="Create New Deck"
|
||||
buttonText="Create New Deck"
|
||||
/>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/profile')}
|
||||
style={[
|
||||
styles.iconButton,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="account-circle" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings')}
|
||||
style={[
|
||||
styles.iconButton,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="settings" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
for (const deck of userDecks) {
|
||||
try {
|
||||
const slides = await getDeckSlides(deck.id);
|
||||
if (slides.length > 0) {
|
||||
firstSlides[deck.id] = slides[0].imageUrl || '';
|
||||
}
|
||||
counts[deck.id] = slides.length;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting slides:', error);
|
||||
}
|
||||
}
|
||||
|
||||
{/* Create Deck Modal */}
|
||||
<Modal
|
||||
visible={isCreateModalVisible}
|
||||
onRequestClose={() => setIsCreateModalVisible(false)}
|
||||
animationType="fade"
|
||||
transparent
|
||||
>
|
||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.5)' }]}>
|
||||
<View style={[styles.modalContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<CreateDeckForm
|
||||
userId={user?.uid || ''}
|
||||
onSuccess={(newDeck) => {
|
||||
setIsCreateModalVisible(false);
|
||||
setDecks([newDeck, ...decks]);
|
||||
router.push(`/deck/${newDeck.id}`);
|
||||
}}
|
||||
onCancel={() => setIsCreateModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
if (mounted) {
|
||||
setDecksWithFirstSlide(firstSlides);
|
||||
setSlideCounts(counts);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Error loading decks:', error);
|
||||
if (mounted) {
|
||||
setError('Failed to load decks');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{/* Delete Deck Modal */}
|
||||
<Modal
|
||||
visible={isDeleteModalVisible}
|
||||
onRequestClose={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}}
|
||||
transparent
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.modalTitle, { color: theme.colors.textPrimary }]}>Delete Deck</Text>
|
||||
<Text style={[styles.modalText, { color: theme.colors.textSecondary }]}>
|
||||
Are you sure you want to delete "{deckToDelete?.name}"? This action cannot be undone.
|
||||
</Text>
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, { backgroundColor: theme.colors.error }]}
|
||||
onPress={confirmDelete}
|
||||
>
|
||||
<Text style={[styles.buttonText, styles.deleteButtonText]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||
console.log('[App] Auth state changed:', currentUser?.email);
|
||||
|
||||
{/* Share Deck Modal */}
|
||||
<Modal
|
||||
visible={isShareModalVisible}
|
||||
onRequestClose={() => setIsShareModalVisible(false)}
|
||||
transparent
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<DeckShareSettings
|
||||
deck={deckToShare!}
|
||||
onUpdateSharing={async (sharing) => {
|
||||
if (!deckToShare) return;
|
||||
try {
|
||||
await updateDoc(doc(db, 'decks', deckToShare.id), { sharing });
|
||||
// Refresh decks list
|
||||
if (user) {
|
||||
const updatedDecks = await getUserDecks(user.uid);
|
||||
setDecks(updatedDecks);
|
||||
}
|
||||
setIsShareModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating sharing settings:', error);
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsShareModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
if (!mounted) return;
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
if (currentUser) {
|
||||
setUser(currentUser);
|
||||
await loadDecksForUser(currentUser);
|
||||
} else {
|
||||
setUser(null);
|
||||
setDecks([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeckPress = (deck: Deck) => {
|
||||
router.push(`/deck/${deck.id}`);
|
||||
};
|
||||
|
||||
const handleDeckDelete = (deck: Deck) => {
|
||||
setDeckToDelete(deck);
|
||||
setIsDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShareDeck = (deck: Deck) => {
|
||||
setDeckToShare(deck);
|
||||
setIsShareModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deckToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteDeck(deckToDelete.id);
|
||||
const updatedDecks = decks.filter((deck) => deck.id !== deckToDelete.id);
|
||||
setDecks(updatedDecks);
|
||||
} catch (error) {
|
||||
console.error('[App] Error deleting deck:', error);
|
||||
setError('Failed to delete deck');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={styles.content}>
|
||||
<DeckList
|
||||
decks={decks}
|
||||
onDeckPress={(deck) => {
|
||||
router.push(`/deck/${deck.id}`);
|
||||
}}
|
||||
onCreateDeck={() => setIsCreateModalVisible(true)}
|
||||
onDeleteDeck={(deck) => {
|
||||
setDeckToDelete(deck);
|
||||
setIsDeleteModalVisible(true);
|
||||
}}
|
||||
onShareDeck={(deck) => {
|
||||
setDeckToShare(deck);
|
||||
setIsShareModalVisible(true);
|
||||
}}
|
||||
firstSlideImages={decksWithFirstSlide}
|
||||
loading={loading}
|
||||
slideCounts={slideCounts}
|
||||
onProfilePress={() => router.push('/profile')}
|
||||
onSettingsPress={() => router.push('/settings')}
|
||||
scrollPadding={{
|
||||
vertical: { top: 20, bottom: 80 },
|
||||
horizontal: { top: 200, bottom: 200 },
|
||||
}}
|
||||
deckSpacing={{ vertical: 24, horizontal: 40 }}
|
||||
headerRight={() => (
|
||||
<View style={{ flexDirection: 'row', gap: 15, marginRight: 15 }}>
|
||||
<CreateItemButton
|
||||
onPress={() => setIsCreateModalVisible(true)}
|
||||
variant="button"
|
||||
title="Create New Deck"
|
||||
buttonText="Create New Deck"
|
||||
/>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/profile')}
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<MaterialIcons name="account-circle" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings')}
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<MaterialIcons name="settings" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Create Deck Modal */}
|
||||
<Modal
|
||||
visible={isCreateModalVisible}
|
||||
onRequestClose={() => setIsCreateModalVisible(false)}
|
||||
animationType="fade"
|
||||
transparent
|
||||
>
|
||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.5)' }]}>
|
||||
<View
|
||||
style={[styles.modalContainer, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<CreateDeckForm
|
||||
userId={user?.uid || ''}
|
||||
onSuccess={(newDeck) => {
|
||||
setIsCreateModalVisible(false);
|
||||
setDecks([newDeck, ...decks]);
|
||||
router.push(`/deck/${newDeck.id}`);
|
||||
}}
|
||||
onCancel={() => setIsCreateModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Deck Modal */}
|
||||
<Modal
|
||||
visible={isDeleteModalVisible}
|
||||
onRequestClose={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}}
|
||||
transparent
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[styles.modalContent, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: theme.colors.textPrimary }]}>
|
||||
Delete Deck
|
||||
</Text>
|
||||
<Text style={[styles.modalText, { color: theme.colors.textSecondary }]}>
|
||||
Are you sure you want to delete "{deckToDelete?.name}"? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
onPress={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, { backgroundColor: theme.colors.error }]}
|
||||
onPress={confirmDelete}
|
||||
>
|
||||
<Text style={[styles.buttonText, styles.deleteButtonText]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Share Deck Modal */}
|
||||
<Modal
|
||||
visible={isShareModalVisible}
|
||||
onRequestClose={() => setIsShareModalVisible(false)}
|
||||
transparent
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[styles.modalContent, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<DeckShareSettings
|
||||
deck={deckToShare!}
|
||||
onUpdateSharing={async (sharing) => {
|
||||
if (!deckToShare) return;
|
||||
try {
|
||||
await updateDoc(doc(db, 'decks', deckToShare.id), { sharing });
|
||||
// Refresh decks list
|
||||
if (user) {
|
||||
const updatedDecks = await getUserDecks(user.uid);
|
||||
setDecks(updatedDecks);
|
||||
}
|
||||
setIsShareModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating sharing settings:', error);
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsShareModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalContent: {
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
width: '80%',
|
||||
maxWidth: 500,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
modalText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 5,
|
||||
elevation: 5,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalContent: {
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
width: '80%',
|
||||
maxWidth: 500,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
modalText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 5,
|
||||
elevation: 5,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,135 +6,133 @@ import { auth } from '../firebaseConfig';
|
|||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface UserStats {
|
||||
totalDecks: number;
|
||||
totalSlides: number;
|
||||
totalDecks: number;
|
||||
totalSlides: number;
|
||||
}
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { theme } = useTheme();
|
||||
const [stats, setStats] = useState<UserStats>({ totalDecks: 0, totalSlides: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { theme } = useTheme();
|
||||
const [stats, setStats] = useState<UserStats>({ totalDecks: 0, totalSlides: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
if (!auth.currentUser) return;
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
if (!auth.currentUser) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const decks = await getUserDecks(auth.currentUser.uid);
|
||||
let totalSlides = 0;
|
||||
try {
|
||||
setLoading(true);
|
||||
const decks = await getUserDecks(auth.currentUser.uid);
|
||||
let totalSlides = 0;
|
||||
|
||||
// Get slides for each deck
|
||||
for (const deck of decks) {
|
||||
const slides = await getDeckSlides(deck.id);
|
||||
totalSlides += slides.length;
|
||||
}
|
||||
// Get slides for each deck
|
||||
for (const deck of decks) {
|
||||
const slides = await getDeckSlides(deck.id);
|
||||
totalSlides += slides.length;
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalDecks: decks.length,
|
||||
totalSlides: totalSlides
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
setStats({
|
||||
totalDecks: decks.length,
|
||||
totalSlides: totalSlides,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
}, []);
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<Text style={[styles.loadingText, { color: theme.colors.textPrimary }]}>
|
||||
Loading stats...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<Text style={[styles.loadingText, { color: theme.colors.textPrimary }]}>
|
||||
Loading stats...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<MaterialIcons name="account-circle" size={80} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
<Text style={[styles.userName, { color: theme.colors.textPrimary }]}>
|
||||
{auth.currentUser?.email || 'User'}
|
||||
</Text>
|
||||
</View>
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<MaterialIcons name="account-circle" size={80} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
<Text style={[styles.userName, { color: theme.colors.textPrimary }]}>
|
||||
{auth.currentUser?.email || 'User'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statsContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View style={styles.statItem}>
|
||||
<MaterialIcons name="folder" size={32} color={theme.colors.textPrimary} />
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{stats.totalDecks}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total Decks
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.statsContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View style={styles.statItem}>
|
||||
<MaterialIcons name="folder" size={32} color={theme.colors.textPrimary} />
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{stats.totalDecks}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textSecondary }]}>Total Decks</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.borderPrimary }]} />
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.borderPrimary }]} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<MaterialIcons name="slideshow" size={32} color={theme.colors.textPrimary} />
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{stats.totalSlides}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total Slides
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<View style={styles.statItem}>
|
||||
<MaterialIcons name="slideshow" size={32} color={theme.colors.textPrimary} />
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{stats.totalSlides}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total Slides
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: '80%',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: '80%',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Pressable, Image } from 'react-native';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { getCurrentUser, logoutUser } from '../services/auth';
|
||||
import { useTheme } from '../components/ThemeProvider';
|
||||
|
|
@ -8,145 +16,148 @@ import { ThemeVariant, getTheme, THEME_PATTERNS, THEME_NAMES } from '../constant
|
|||
import { ThemeSettings } from '../components/common/ThemeSettings';
|
||||
|
||||
const COLOR_MODES: { label: string; value: ColorMode }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
];
|
||||
|
||||
const CONTRAST_LABELS: Record<ContrastLevel, string> = {
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
};
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const currentUser = getCurrentUser();
|
||||
const { theme, isDark } = useTheme();
|
||||
const router = useRouter();
|
||||
const currentUser = getCurrentUser();
|
||||
const { theme, isDark } = useTheme();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
router.replace('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
router.replace('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={styles.content}>
|
||||
<ThemeSettings />
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor: level === 3
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
}
|
||||
]}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[3]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={styles.content}>
|
||||
<ThemeSettings />
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor:
|
||||
level === 3 ? theme.colors.primary : theme.colors.backgroundSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[3]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomSection}>
|
||||
<View style={[styles.emailSection, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Email</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{currentUser?.email}</Text>
|
||||
</View>
|
||||
<View style={styles.bottomSection}>
|
||||
<View style={[styles.emailSection, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Email</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>
|
||||
{currentUser?.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.logoutButton, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<Text style={[styles.logoutButtonText, { color: theme.colors.textPrimary }]}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
<TouchableOpacity
|
||||
style={[styles.logoutButton, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<Text style={[styles.logoutButtonText, { color: theme.colors.textPrimary }]}>
|
||||
Sign Out
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
sectionTitleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
height: 48,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomSection: {
|
||||
gap: 12,
|
||||
marginTop: 'auto',
|
||||
},
|
||||
emailSection: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoutButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
sectionTitleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
height: 48,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomSection: {
|
||||
gap: 12,
|
||||
marginTop: 'auto',
|
||||
},
|
||||
emailSection: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoutButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,87 +8,87 @@ import { db } from '../../firebaseConfig';
|
|||
import { SlideList } from '../../components/slides/SlideList';
|
||||
|
||||
export default function SharedDeckView() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const { theme } = useTheme();
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { id } = useLocalSearchParams();
|
||||
const { theme } = useTheme();
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeck = async () => {
|
||||
try {
|
||||
const deckDoc = await getDoc(doc(db, 'decks', id as string));
|
||||
if (!deckDoc.exists()) {
|
||||
setError('Deck not found');
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const fetchDeck = async () => {
|
||||
try {
|
||||
const deckDoc = await getDoc(doc(db, 'decks', id as string));
|
||||
if (!deckDoc.exists()) {
|
||||
setError('Deck not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const deckData = deckDoc.data() as Deck;
|
||||
if (!deckData.sharing.isPublic) {
|
||||
setError('This deck is not publicly accessible');
|
||||
return;
|
||||
}
|
||||
const deckData = deckDoc.data() as Deck;
|
||||
if (!deckData.sharing.isPublic) {
|
||||
setError('This deck is not publicly accessible');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeck(deckData);
|
||||
} catch (err) {
|
||||
setError('Failed to load deck');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
setDeck(deckData);
|
||||
} catch (err) {
|
||||
setError('Failed to load deck');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDeck();
|
||||
}, [id]);
|
||||
fetchDeck();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.text, { color: theme.colors.textPrimary }]}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.text, { color: theme.colors.textPrimary }]}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.text, { color: theme.colors.error }]}>{error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.text, { color: theme.colors.error }]}>{error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deck) {
|
||||
return null;
|
||||
}
|
||||
if (!deck) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{deck.name}</Text>
|
||||
{deck.description && (
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{deck.description}
|
||||
</Text>
|
||||
)}
|
||||
<SlideList deckId={id as string} isReadOnly />
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{deck.name}</Text>
|
||||
{deck.description && (
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{deck.description}
|
||||
</Text>
|
||||
)}
|
||||
<SlideList deckId={id as string} isReadOnly />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,129 +5,122 @@ import { useTheme, ThemeVariant } from '../components/ThemeProvider';
|
|||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
const THEME_PATTERNS: Record<ThemeVariant, any> = {
|
||||
lume: require('../assets/images/patterns/memo-theme-tile.png'),
|
||||
nature: require('../assets/images/patterns/nature-theme-tile.png'),
|
||||
stone: require('../assets/images/patterns/stone-theme-tile.png'),
|
||||
lume: require('../assets/images/patterns/memo-theme-tile.png'),
|
||||
nature: require('../assets/images/patterns/nature-theme-tile.png'),
|
||||
stone: require('../assets/images/patterns/stone-theme-tile.png'),
|
||||
};
|
||||
|
||||
const THEME_NAMES: Record<ThemeVariant, string> = {
|
||||
lume: 'Lume',
|
||||
nature: 'Nature',
|
||||
stone: 'Stone',
|
||||
lume: 'Lume',
|
||||
nature: 'Nature',
|
||||
stone: 'Stone',
|
||||
};
|
||||
|
||||
export default function ThemesScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, themeVariant, setThemeVariant } = useTheme();
|
||||
const router = useRouter();
|
||||
const { theme, themeVariant, setThemeVariant } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Designs</Text>
|
||||
</View>
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<MaterialIcons name="arrow-back" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Designs</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<View style={styles.themeGrid}>
|
||||
{(Object.keys(THEME_NAMES) as ThemeVariant[]).map((variant) => {
|
||||
const isSelected = variant === themeVariant;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={variant}
|
||||
style={[
|
||||
styles.themeCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent',
|
||||
borderWidth: isSelected ? 2 : 0,
|
||||
}
|
||||
]}
|
||||
onPress={() => setThemeVariant(variant)}
|
||||
>
|
||||
{THEME_PATTERNS[variant] && (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<View style={styles.patternContainer}>
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
source={THEME_PATTERNS[variant]}
|
||||
style={[styles.patternTile, { opacity: 0.15 }]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.themeName, { color: theme.colors.textPrimary }]}>
|
||||
{THEME_NAMES[variant]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
<ScrollView style={styles.content}>
|
||||
<View style={styles.themeGrid}>
|
||||
{(Object.keys(THEME_NAMES) as ThemeVariant[]).map((variant) => {
|
||||
const isSelected = variant === themeVariant;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={variant}
|
||||
style={[
|
||||
styles.themeCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent',
|
||||
borderWidth: isSelected ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
onPress={() => setThemeVariant(variant)}
|
||||
>
|
||||
{THEME_PATTERNS[variant] && (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<View style={styles.patternContainer}>
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
source={THEME_PATTERNS[variant]}
|
||||
style={[styles.patternTile, { opacity: 0.15 }]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.themeName, { color: theme.colors.textPrimary }]}>
|
||||
{THEME_NAMES[variant]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingTop: 60,
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
themeGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
},
|
||||
themeCard: {
|
||||
width: '100%',
|
||||
aspectRatio: 2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
patternContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
patternTile: {
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
},
|
||||
themeName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingTop: 60,
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
themeGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
},
|
||||
themeCard: {
|
||||
width: '100%',
|
||||
aspectRatio: 2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
patternContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
patternTile: {
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
},
|
||||
themeName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,183 +5,152 @@ import { useTheme } from '../ThemeProvider';
|
|||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
showAddDeck?: boolean;
|
||||
showPresent?: boolean;
|
||||
onPresentPress?: () => void;
|
||||
disabled?: boolean;
|
||||
slideCount?: number;
|
||||
rightContent?: React.ReactNode;
|
||||
position?: 'top' | 'bottom';
|
||||
title: string;
|
||||
showAddDeck?: boolean;
|
||||
showPresent?: boolean;
|
||||
onPresentPress?: () => void;
|
||||
disabled?: boolean;
|
||||
slideCount?: number;
|
||||
rightContent?: React.ReactNode;
|
||||
position?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
showAddDeck = false,
|
||||
showPresent = false,
|
||||
onPresentPress,
|
||||
disabled = false,
|
||||
slideCount,
|
||||
rightContent,
|
||||
position = 'top'
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
showAddDeck = false,
|
||||
showPresent = false,
|
||||
onPresentPress,
|
||||
disabled = false,
|
||||
slideCount,
|
||||
rightContent,
|
||||
position = 'top',
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
const shouldBeBottom = isMobile && position === 'bottom';
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
const shouldBeBottom = isMobile && position === 'bottom';
|
||||
|
||||
const defaultRightContent = (
|
||||
<View style={styles.rightContent}>
|
||||
{!showPresent && (
|
||||
<View style={[
|
||||
styles.iconWrapper,
|
||||
{ borderColor: theme.colors.borderPrimary }
|
||||
]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/profile')}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="account-circle"
|
||||
size={24}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{showPresent && (
|
||||
<View style={[
|
||||
styles.iconWrapper,
|
||||
{ borderColor: disabled ? theme.colors.borderPrimary : theme.colors.primary }
|
||||
]}>
|
||||
<TouchableOpacity
|
||||
onPress={onPresentPress}
|
||||
style={styles.iconButton}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="slideshow"
|
||||
size={24}
|
||||
color={disabled ? theme.colors.textTertiary : theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<View style={[
|
||||
styles.iconWrapper,
|
||||
{ borderColor: theme.colors.borderPrimary }
|
||||
]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings')}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="settings"
|
||||
size={24}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{showAddDeck && (
|
||||
<View style={[
|
||||
styles.iconWrapper,
|
||||
{ borderColor: theme.colors.primary }
|
||||
]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const event = new CustomEvent('openCreateDeckModal');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="add"
|
||||
size={24}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
const defaultRightContent = (
|
||||
<View style={styles.rightContent}>
|
||||
{!showPresent && (
|
||||
<View style={[styles.iconWrapper, { borderColor: theme.colors.borderPrimary }]}>
|
||||
<TouchableOpacity onPress={() => router.push('/profile')} style={styles.iconButton}>
|
||||
<MaterialIcons name="account-circle" size={24} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{showPresent && (
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrapper,
|
||||
{ borderColor: disabled ? theme.colors.borderPrimary : theme.colors.primary },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity onPress={onPresentPress} style={styles.iconButton} disabled={disabled}>
|
||||
<MaterialIcons
|
||||
name="slideshow"
|
||||
size={24}
|
||||
color={disabled ? theme.colors.textTertiary : theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.iconWrapper, { borderColor: theme.colors.borderPrimary }]}>
|
||||
<TouchableOpacity onPress={() => router.push('/settings')} style={styles.iconButton}>
|
||||
<MaterialIcons name="settings" size={24} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{showAddDeck && (
|
||||
<View style={[styles.iconWrapper, { borderColor: theme.colors.primary }]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const event = new CustomEvent('openCreateDeckModal');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<MaterialIcons name="add" size={24} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.header,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderBottomColor: shouldBeBottom ? 'transparent' : theme.colors.borderPrimary,
|
||||
borderTopColor: shouldBeBottom ? theme.colors.borderPrimary : 'transparent',
|
||||
borderTopWidth: shouldBeBottom ? 1 : 0,
|
||||
borderBottomWidth: shouldBeBottom ? 0 : 1,
|
||||
}
|
||||
]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<View style={styles.titleContent}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>
|
||||
{title}
|
||||
</Text>
|
||||
{typeof slideCount === 'number' && (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{slideCount} Slides
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.rightContainer}>
|
||||
{rightContent || defaultRightContent}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderBottomColor: shouldBeBottom ? 'transparent' : theme.colors.borderPrimary,
|
||||
borderTopColor: shouldBeBottom ? theme.colors.borderPrimary : 'transparent',
|
||||
borderTopWidth: shouldBeBottom ? 1 : 0,
|
||||
borderBottomWidth: shouldBeBottom ? 0 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<View style={styles.titleContent}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{typeof slideCount === 'number' && (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{slideCount} Slides
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.rightContainer}>{rightContent || defaultRightContent}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: 56,
|
||||
gap: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
maxWidth: '50%',
|
||||
},
|
||||
titleContent: {
|
||||
padding: 8,
|
||||
width: '100%',
|
||||
},
|
||||
rightContainer: {
|
||||
flex: 1,
|
||||
maxWidth: '50%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
rightContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
},
|
||||
iconWrapper: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: 56,
|
||||
gap: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
maxWidth: '50%',
|
||||
},
|
||||
titleContent: {
|
||||
padding: 8,
|
||||
width: '100%',
|
||||
},
|
||||
rightContainer: {
|
||||
flex: 1,
|
||||
maxWidth: '50%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
rightContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
},
|
||||
iconWrapper: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,141 +9,153 @@ type ColorType = 'text' | 'primary' | 'background';
|
|||
|
||||
// Hilfsfunktion zum Konvertieren von Hex zu RGB
|
||||
const hexToRgb = (hex: string) => {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.substr(0, 2), 16),
|
||||
g: parseInt(h.substr(2, 2), 16),
|
||||
b: parseInt(h.substr(4, 2), 16),
|
||||
};
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.substr(0, 2), 16),
|
||||
g: parseInt(h.substr(2, 2), 16),
|
||||
b: parseInt(h.substr(4, 2), 16),
|
||||
};
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von RGB zu Hex mit Alpha
|
||||
const rgbaToHex = (r: number, g: number, b: number, a: number = 1) => {
|
||||
const alpha = Math.round(a * 255);
|
||||
return '#' + [r, g, b, alpha].map(x => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
const alpha = Math.round(a * 255);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b, alpha]
|
||||
.map((x) => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
// Funktion zum Anpassen des Kontrasts
|
||||
const adjustContrast = (color: string, level: ContrastLevel, type: ColorType, isDark: boolean): string => {
|
||||
if (level === 3) return color;
|
||||
const adjustContrast = (
|
||||
color: string,
|
||||
level: ContrastLevel,
|
||||
type: ColorType,
|
||||
isDark: boolean
|
||||
): string => {
|
||||
if (level === 3) return color;
|
||||
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
|
||||
if (level < 3) {
|
||||
// Niedrigerer Kontrast: Nur Text-Opacity wird reduziert
|
||||
if (type === 'text') {
|
||||
const opacity = 0.5 + (level - 1) * 0.25; // 0.5 für Level 1, 0.75 für Level 2
|
||||
return rgbaToHex(r, g, b, opacity);
|
||||
}
|
||||
return color;
|
||||
} else {
|
||||
// Höherer Kontrast: Nur Hintergründe werden angepasst
|
||||
if (type === 'background') {
|
||||
const factor = (level - 3) * 0.45; // 0.45 für Level 4, 0.9 für Level 5
|
||||
if (isDark) {
|
||||
// Im Dark Mode: Hintergründe werden schwärzer
|
||||
return rgbaToHex(
|
||||
Math.round(r * (1 - factor)),
|
||||
Math.round(g * (1 - factor)),
|
||||
Math.round(b * (1 - factor))
|
||||
);
|
||||
} else {
|
||||
// Im Light Mode: Hintergründe werden weißer
|
||||
return rgbaToHex(
|
||||
Math.round(r + (255 - r) * factor),
|
||||
Math.round(g + (255 - g) * factor),
|
||||
Math.round(b + (255 - b) * factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
|
||||
if (level < 3) {
|
||||
// Niedrigerer Kontrast: Nur Text-Opacity wird reduziert
|
||||
if (type === 'text') {
|
||||
const opacity = 0.5 + (level - 1) * 0.25; // 0.5 für Level 1, 0.75 für Level 2
|
||||
return rgbaToHex(r, g, b, opacity);
|
||||
}
|
||||
return color;
|
||||
} else {
|
||||
// Höherer Kontrast: Nur Hintergründe werden angepasst
|
||||
if (type === 'background') {
|
||||
const factor = (level - 3) * 0.45; // 0.45 für Level 4, 0.9 für Level 5
|
||||
if (isDark) {
|
||||
// Im Dark Mode: Hintergründe werden schwärzer
|
||||
return rgbaToHex(
|
||||
Math.round(r * (1 - factor)),
|
||||
Math.round(g * (1 - factor)),
|
||||
Math.round(b * (1 - factor))
|
||||
);
|
||||
} else {
|
||||
// Im Light Mode: Hintergründe werden weißer
|
||||
return rgbaToHex(
|
||||
Math.round(r + (255 - r) * factor),
|
||||
Math.round(g + (255 - g) * factor),
|
||||
Math.round(b + (255 - b) * factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
const adjustThemeContrast = (theme: Theme, level: ContrastLevel, isDark: boolean): Theme => {
|
||||
if (level === 3) return theme;
|
||||
if (level === 3) return theme;
|
||||
|
||||
const adjustedColors = Object.entries(theme.colors).reduce((acc, [key, value]) => {
|
||||
if (typeof value === 'string' && value.startsWith('#')) {
|
||||
let colorType: ColorType = 'background';
|
||||
if (key.toLowerCase().includes('text')) {
|
||||
colorType = 'text';
|
||||
} else if (key.toLowerCase().includes('primary')) {
|
||||
colorType = 'primary';
|
||||
}
|
||||
|
||||
acc[key] = adjustContrast(value, level, colorType, isDark);
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
const adjustedColors = Object.entries(theme.colors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (typeof value === 'string' && value.startsWith('#')) {
|
||||
let colorType: ColorType = 'background';
|
||||
if (key.toLowerCase().includes('text')) {
|
||||
colorType = 'text';
|
||||
} else if (key.toLowerCase().includes('primary')) {
|
||||
colorType = 'primary';
|
||||
}
|
||||
|
||||
return {
|
||||
...theme,
|
||||
colors: adjustedColors,
|
||||
};
|
||||
acc[key] = adjustContrast(value, level, colorType, isDark);
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
return {
|
||||
...theme,
|
||||
colors: adjustedColors,
|
||||
};
|
||||
};
|
||||
|
||||
type ThemeContextType = {
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
themeVariant: ThemeVariant;
|
||||
setThemeVariant: (variant: ThemeVariant) => void;
|
||||
contrastLevel: ContrastLevel;
|
||||
setContrastLevel: (level: ContrastLevel) => void;
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
themeVariant: ThemeVariant;
|
||||
setThemeVariant: (variant: ThemeVariant) => void;
|
||||
contrastLevel: ContrastLevel;
|
||||
setContrastLevel: (level: ContrastLevel) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: getTheme('light'),
|
||||
isDark: false,
|
||||
colorMode: 'system',
|
||||
setColorMode: () => {},
|
||||
themeVariant: 'default',
|
||||
setThemeVariant: () => {},
|
||||
contrastLevel: 3,
|
||||
setContrastLevel: () => {},
|
||||
theme: getTheme('light'),
|
||||
isDark: false,
|
||||
colorMode: 'system',
|
||||
setColorMode: () => {},
|
||||
themeVariant: 'default',
|
||||
setThemeVariant: () => {},
|
||||
contrastLevel: 3,
|
||||
setContrastLevel: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('system');
|
||||
const [themeVariant, setThemeVariant] = useState<ThemeVariant>('default');
|
||||
const [contrastLevel, setContrastLevel] = useState<ContrastLevel>(3);
|
||||
|
||||
const isDark = useMemo(() => {
|
||||
if (colorMode === 'system') {
|
||||
return systemColorScheme === 'dark';
|
||||
}
|
||||
return colorMode === 'dark';
|
||||
}, [colorMode, systemColorScheme]);
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('system');
|
||||
const [themeVariant, setThemeVariant] = useState<ThemeVariant>('default');
|
||||
const [contrastLevel, setContrastLevel] = useState<ContrastLevel>(3);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDark ? 'dark' : 'light', themeVariant);
|
||||
return adjustThemeContrast(baseTheme, contrastLevel, isDark);
|
||||
}, [isDark, themeVariant, contrastLevel]);
|
||||
const isDark = useMemo(() => {
|
||||
if (colorMode === 'system') {
|
||||
return systemColorScheme === 'dark';
|
||||
}
|
||||
return colorMode === 'dark';
|
||||
}, [colorMode, systemColorScheme]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
theme,
|
||||
isDark,
|
||||
colorMode,
|
||||
setColorMode,
|
||||
themeVariant,
|
||||
setThemeVariant,
|
||||
contrastLevel,
|
||||
setContrastLevel,
|
||||
}), [theme, isDark, colorMode, themeVariant, contrastLevel]);
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDark ? 'dark' : 'light', themeVariant);
|
||||
return adjustThemeContrast(baseTheme, contrastLevel, isDark);
|
||||
}, [isDark, themeVariant, contrastLevel]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
isDark,
|
||||
colorMode,
|
||||
setColorMode,
|
||||
themeVariant,
|
||||
setThemeVariant,
|
||||
contrastLevel,
|
||||
setContrastLevel,
|
||||
}),
|
||||
[theme, isDark, colorMode, themeVariant, contrastLevel]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,74 +2,68 @@ import { TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-nat
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
interface ButtonProps {
|
||||
onPress: () => void;
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
onPress,
|
||||
children,
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled = false
|
||||
export const Button = ({
|
||||
onPress,
|
||||
children,
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={loading || disabled}
|
||||
style={[
|
||||
styles.button,
|
||||
styles[variant],
|
||||
(loading || disabled) && styles.disabled
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={[styles.text, styles[`${variant}Text`]]}>
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={loading || disabled}
|
||||
style={[styles.button, styles[variant], (loading || disabled) && styles.disabled]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={[styles.text, styles[`${variant}Text`]]}>{children}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 200,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#f4511e',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderColor: '#f4511e',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#fff',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#fff',
|
||||
},
|
||||
outlineText: {
|
||||
color: '#f4511e',
|
||||
},
|
||||
});
|
||||
button: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 200,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#f4511e',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderColor: '#f4511e',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#fff',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#fff',
|
||||
},
|
||||
outlineText: {
|
||||
color: '#f4511e',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,59 +1,59 @@
|
|||
import { TextInput, View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
interface InputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
label?: string;
|
||||
error?: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
secureTextEntry = false,
|
||||
label,
|
||||
error
|
||||
export const Input = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
secureTextEntry = false,
|
||||
label,
|
||||
error,
|
||||
}: InputProps) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
secureTextEntry={secureTextEntry}
|
||||
style={[styles.input, error && styles.inputError]}
|
||||
/>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
secureTextEntry={secureTextEntry}
|
||||
style={[styles.input, error && styles.inputError]}
|
||||
/>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 8,
|
||||
color: '#000',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ff0000',
|
||||
},
|
||||
error: {
|
||||
color: '#ff0000',
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 8,
|
||||
color: '#000',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ff0000',
|
||||
},
|
||||
error: {
|
||||
color: '#ff0000',
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,114 +3,98 @@ import { View, Modal, TouchableOpacity, Text, StyleSheet, Platform } from 'react
|
|||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
onPress: () => void;
|
||||
destructive?: boolean;
|
||||
label: string;
|
||||
icon: string;
|
||||
onPress: () => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
items: ContextMenuItem[];
|
||||
position?: { x: number; y: number };
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
items: ContextMenuItem[];
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
items,
|
||||
position,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({ visible, onClose, items, position }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!visible) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.overlay}
|
||||
onPress={onClose}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
top: position?.y || 0,
|
||||
left: position?.x || 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
index < items.length - 1 && styles.menuItemBorder,
|
||||
{ borderBottomColor: theme.colors.border }
|
||||
]}
|
||||
onPress={() => {
|
||||
item.onPress();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.menuItemText,
|
||||
{
|
||||
color: item.destructive
|
||||
? theme.colors.error
|
||||
: theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<TouchableOpacity style={styles.overlay} onPress={onClose} activeOpacity={1}>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
top: position?.y || 0,
|
||||
left: position?.x || 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
index < items.length - 1 && styles.menuItemBorder,
|
||||
{ borderBottomColor: theme.colors.border },
|
||||
]}
|
||||
onPress={() => {
|
||||
item.onPress();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.menuItemText,
|
||||
{
|
||||
color: item.destructive ? theme.colors.error : theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
menuContainer: {
|
||||
position: 'absolute',
|
||||
minWidth: 150,
|
||||
borderRadius: 8,
|
||||
...Platform.select({
|
||||
web: {
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
default: {
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
menuItem: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
menuItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
menuContainer: {
|
||||
position: 'absolute',
|
||||
minWidth: 150,
|
||||
borderRadius: 8,
|
||||
...Platform.select({
|
||||
web: {
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
default: {
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
menuItem: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
menuItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,114 +1,104 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { TouchableOpacity, View, Text, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface CreateItemButtonProps {
|
||||
onPress: () => void;
|
||||
variant?: 'card' | 'button';
|
||||
width?: number | 'auto';
|
||||
title?: string;
|
||||
buttonText?: string;
|
||||
icon?: keyof typeof MaterialIcons.glyphMap;
|
||||
buttonIcon?: keyof typeof MaterialIcons.glyphMap;
|
||||
onPress: () => void;
|
||||
variant?: 'card' | 'button';
|
||||
width?: number | 'auto';
|
||||
title?: string;
|
||||
buttonText?: string;
|
||||
icon?: keyof typeof MaterialIcons.glyphMap;
|
||||
buttonIcon?: keyof typeof MaterialIcons.glyphMap;
|
||||
}
|
||||
|
||||
export const CreateItemButton: React.FC<CreateItemButtonProps> = ({
|
||||
onPress,
|
||||
variant = 'card',
|
||||
width = 'auto',
|
||||
title = 'Create New Item',
|
||||
buttonText = 'Create New',
|
||||
icon = 'add',
|
||||
buttonIcon = 'add-circle-outline'
|
||||
export const CreateItemButton: React.FC<CreateItemButtonProps> = ({
|
||||
onPress,
|
||||
variant = 'card',
|
||||
width = 'auto',
|
||||
title = 'Create New Item',
|
||||
buttonText = 'Create New',
|
||||
icon = 'add',
|
||||
buttonIcon = 'add-circle-outline',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<MaterialIcons name={buttonIcon} size={24} color="#FFFFFF" />
|
||||
<Text style={[styles.createButtonText, { color: '#FFFFFF' }]}>
|
||||
{buttonText}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<MaterialIcons name={buttonIcon} size={24} color="#FFFFFF" />
|
||||
<Text style={[styles.createButtonText, { color: '#FFFFFF' }]}>{buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
{ width },
|
||||
{ backgroundColor: 'transparent' }
|
||||
]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.itemContent}>
|
||||
<View style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name={icon} size={48} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width }, { backgroundColor: 'transparent' }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.itemContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name={icon} size={48} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
itemContainer: {
|
||||
marginVertical: 0,
|
||||
marginHorizontal: 0,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
itemContent: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
aspectRatio: 16/9,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
padding: 0,
|
||||
marginTop: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
createButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
createButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
itemContainer: {
|
||||
marginVertical: 0,
|
||||
marginHorizontal: 0,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
itemContent: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
padding: 0,
|
||||
marginTop: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
createButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
createButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,133 +3,125 @@ import { View, Text, TouchableOpacity, StyleSheet, Pressable } from 'react-nativ
|
|||
import { useTheme, ColorMode, ContrastLevel } from '../ThemeProvider';
|
||||
|
||||
const COLOR_MODES: { label: string; value: ColorMode }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
];
|
||||
|
||||
const CONTRAST_LABELS: Record<ContrastLevel, string> = {
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
};
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const {
|
||||
theme,
|
||||
colorMode,
|
||||
setColorMode,
|
||||
contrastLevel,
|
||||
setContrastLevel,
|
||||
} = useTheme();
|
||||
const { theme, colorMode, setColorMode, contrastLevel, setContrastLevel } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Helligkeits-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Helligkeit:
|
||||
</Text>
|
||||
<View style={styles.colorModeList}>
|
||||
{COLOR_MODES.map(mode => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.colorModeOption,
|
||||
{
|
||||
backgroundColor: mode.value === colorMode
|
||||
? `${theme.colors.primary}1A`
|
||||
: theme.colors.backgroundSecondary,
|
||||
borderColor: mode.value === colorMode ? theme.colors.primary : 'transparent',
|
||||
borderWidth: mode.value === colorMode ? 2 : 0,
|
||||
}
|
||||
]}
|
||||
onPress={() => setColorMode(mode.value)}
|
||||
>
|
||||
<Text style={[styles.colorModeText, { color: theme.colors.textPrimary }]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Helligkeits-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Helligkeit:</Text>
|
||||
<View style={styles.colorModeList}>
|
||||
{COLOR_MODES.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.colorModeOption,
|
||||
{
|
||||
backgroundColor:
|
||||
mode.value === colorMode
|
||||
? `${theme.colors.primary}1A`
|
||||
: theme.colors.backgroundSecondary,
|
||||
borderColor: mode.value === colorMode ? theme.colors.primary : 'transparent',
|
||||
borderWidth: mode.value === colorMode ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
onPress={() => setColorMode(mode.value)}
|
||||
>
|
||||
<Text style={[styles.colorModeText, { color: theme.colors.textPrimary }]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Kontrast-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Kontrast:
|
||||
</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor: level === contrastLevel
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
}
|
||||
]}
|
||||
onPress={() => setContrastLevel(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[contrastLevel]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
{/* Kontrast-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor:
|
||||
level === contrastLevel
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setContrastLevel(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[contrastLevel]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorModeList: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
colorModeOption: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorModeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorModeList: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
colorModeOption: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorModeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,102 +1,92 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { TouchableOpacity, View, Text, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface CreateDeckButtonProps {
|
||||
onPress: () => void;
|
||||
variant?: 'card' | 'button';
|
||||
width?: number | 'auto';
|
||||
onPress: () => void;
|
||||
variant?: 'card' | 'button';
|
||||
width?: number | 'auto';
|
||||
}
|
||||
|
||||
export const CreateDeckButton: React.FC<CreateDeckButtonProps> = ({
|
||||
onPress,
|
||||
variant = 'card',
|
||||
width = 'auto'
|
||||
export const CreateDeckButton: React.FC<CreateDeckButtonProps> = ({
|
||||
onPress,
|
||||
variant = 'card',
|
||||
width = 'auto',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<MaterialIcons name="add-circle-outline" size={24} color="#FFFFFF" />
|
||||
<Text style={[styles.createButtonText, { color: '#FFFFFF' }]}>
|
||||
Create your first deck
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<MaterialIcons name="add-circle-outline" size={24} color="#FFFFFF" />
|
||||
<Text style={[styles.createButtonText, { color: '#FFFFFF' }]}>Create your first deck</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deckContainer,
|
||||
{ width },
|
||||
{ backgroundColor: 'transparent' }
|
||||
]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.deckContent}>
|
||||
<View style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="add" size={48} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create New Deck</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.deckContainer, { width }, { backgroundColor: 'transparent' }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.deckContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="add" size={48} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create New Deck</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deckContainer: {
|
||||
marginVertical: 8,
|
||||
marginHorizontal: 8,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
deckContent: {
|
||||
flex: 1,
|
||||
},
|
||||
imageContainer: {
|
||||
aspectRatio: 16/9,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
padding: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
createButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
createButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deckContainer: {
|
||||
marginVertical: 8,
|
||||
marginHorizontal: 8,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
deckContent: {
|
||||
flex: 1,
|
||||
},
|
||||
imageContainer: {
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
padding: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
createButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
createButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,142 +5,145 @@ import { MaterialIcons } from '@expo/vector-icons';
|
|||
import { useTheme } from '../../components/ThemeProvider';
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: Deck;
|
||||
onPress: (deck: Deck) => void;
|
||||
onDelete: (deck: Deck) => void;
|
||||
onShare: (deck: Deck) => void;
|
||||
firstSlideImage?: string;
|
||||
slideCount: number;
|
||||
deck: Deck;
|
||||
onPress: (deck: Deck) => void;
|
||||
onDelete: (deck: Deck) => void;
|
||||
onShare: (deck: Deck) => void;
|
||||
firstSlideImage?: string;
|
||||
slideCount: number;
|
||||
}
|
||||
|
||||
export const DeckCard: React.FC<DeckCardProps> = ({ deck, onPress, onDelete, onShare, firstSlideImage, slideCount }) => {
|
||||
const { theme } = useTheme();
|
||||
export const DeckCard: React.FC<DeckCardProps> = ({
|
||||
deck,
|
||||
onPress,
|
||||
onDelete,
|
||||
onShare,
|
||||
firstSlideImage,
|
||||
slideCount,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isPublic = deck.sharing?.type === 'public';
|
||||
const isPublic = deck.sharing?.type === 'public';
|
||||
|
||||
const handleDelete = (event: any) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (typeof onDelete === 'function') {
|
||||
onDelete(deck);
|
||||
} else {
|
||||
console.warn('onDelete is not a function');
|
||||
}
|
||||
};
|
||||
const handleDelete = (event: any) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (typeof onDelete === 'function') {
|
||||
onDelete(deck);
|
||||
} else {
|
||||
console.warn('onDelete is not a function');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = (event: any) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (typeof onShare === 'function') {
|
||||
onShare(deck);
|
||||
}
|
||||
};
|
||||
const handleShare = (event: any) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (typeof onShare === 'function') {
|
||||
onShare(deck);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => onPress(deck)}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.imageContainer}>
|
||||
{firstSlideImage ? (
|
||||
<Image
|
||||
source={{ uri: firstSlideImage }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{deck.name}
|
||||
</Text>
|
||||
{isPublic && (
|
||||
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.badgeText}>Public</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.slideCount, { color: theme.colors.textSecondary }]}>
|
||||
{slideCount} {slideCount === 1 ? 'Slide' : 'Slides'}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity onPress={handleShare} style={styles.actionButton}>
|
||||
<MaterialIcons name="share" size={24} color={theme.colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
|
||||
<MaterialIcons name="delete" size={24} color={theme.colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => onPress(deck)}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.imageContainer}>
|
||||
{firstSlideImage ? (
|
||||
<Image source={{ uri: firstSlideImage }} style={styles.thumbnail} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{deck.name}
|
||||
</Text>
|
||||
{isPublic && (
|
||||
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.badgeText}>Public</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.slideCount, { color: theme.colors.textSecondary }]}>
|
||||
{slideCount} {slideCount === 1 ? 'Slide' : 'Slides'}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity onPress={handleShare} style={styles.actionButton}>
|
||||
<MaterialIcons name="share" size={24} color={theme.colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
|
||||
<MaterialIcons name="delete" size={24} color={theme.colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
imageContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
slideCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
imageContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
slideCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Image,
|
||||
useWindowDimensions,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Image,
|
||||
useWindowDimensions,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Deck } from '../../types/models';
|
||||
|
|
@ -17,429 +17,427 @@ import { CreateItemButton } from '../common/CreateItemButton';
|
|||
import { MenuRoot, MenuTrigger, MenuContent, MenuItem, MenuItemTitle } from '../common/menu';
|
||||
|
||||
interface DeckListProps {
|
||||
decks: Deck[];
|
||||
onDeckPress: (deck: Deck) => void;
|
||||
onCreateDeck: () => void;
|
||||
onDeleteDeck: (deck: Deck) => void;
|
||||
onShareDeck: (deck: Deck) => void;
|
||||
firstSlideImages: { [key: string]: string };
|
||||
loading?: boolean;
|
||||
slideCounts: { [key: string]: number };
|
||||
scrollPadding?: {
|
||||
vertical?: { top?: number; bottom?: number };
|
||||
horizontal?: { top?: number; bottom?: number };
|
||||
};
|
||||
deckSpacing?: {
|
||||
vertical?: number;
|
||||
horizontal?: number;
|
||||
};
|
||||
decks: Deck[];
|
||||
onDeckPress: (deck: Deck) => void;
|
||||
onCreateDeck: () => void;
|
||||
onDeleteDeck: (deck: Deck) => void;
|
||||
onShareDeck: (deck: Deck) => void;
|
||||
firstSlideImages: { [key: string]: string };
|
||||
loading?: boolean;
|
||||
slideCounts: { [key: string]: number };
|
||||
scrollPadding?: {
|
||||
vertical?: { top?: number; bottom?: number };
|
||||
horizontal?: { top?: number; bottom?: number };
|
||||
};
|
||||
deckSpacing?: {
|
||||
vertical?: number;
|
||||
horizontal?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DeckList: React.FC<DeckListProps> = ({
|
||||
decks,
|
||||
onDeckPress,
|
||||
onCreateDeck,
|
||||
onDeleteDeck,
|
||||
onShareDeck,
|
||||
firstSlideImages,
|
||||
loading = false,
|
||||
slideCounts,
|
||||
scrollPadding = {
|
||||
vertical: { top: 0, bottom: 0 },
|
||||
horizontal: { top: 0, bottom: 0 }
|
||||
},
|
||||
deckSpacing = { vertical: 8, horizontal: 8 }
|
||||
export const DeckList: React.FC<DeckListProps> = ({
|
||||
decks,
|
||||
onDeckPress,
|
||||
onCreateDeck,
|
||||
onDeleteDeck,
|
||||
onShareDeck,
|
||||
firstSlideImages,
|
||||
loading = false,
|
||||
slideCounts,
|
||||
scrollPadding = {
|
||||
vertical: { top: 0, bottom: 0 },
|
||||
horizontal: { top: 0, bottom: 0 },
|
||||
},
|
||||
deckSpacing = { vertical: 8, horizontal: 8 },
|
||||
}) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const isSmallScreen = width < 768;
|
||||
const deckWidth = !isSmallScreen ? Math.floor((width - 40 - (deckSpacing.horizontal * 2)) / 2.5) : 'auto';
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const { width } = useWindowDimensions();
|
||||
const isSmallScreen = width < 768;
|
||||
const deckWidth = !isSmallScreen
|
||||
? Math.floor((width - 40 - deckSpacing.horizontal * 2) / 2.5)
|
||||
: 'auto';
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (decks.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, styles.emptyState]}>
|
||||
<MaterialIcons name="dashboard" size={48} color={theme.colors.textTertiary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
No decks yet
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtext, { color: theme.colors.textTertiary }]}>
|
||||
Create your first deck to get started
|
||||
</Text>
|
||||
<CreateItemButton
|
||||
onPress={onCreateDeck}
|
||||
variant="button"
|
||||
title="Neues Deck erstellen"
|
||||
buttonText="Erstelle dein erstes Deck"
|
||||
icon="library-add"
|
||||
buttonIcon="library-add"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDeck = ({ item }: { item: Deck | 'create' }) => {
|
||||
if (item === 'create') {
|
||||
return (
|
||||
<CreateItemButton
|
||||
onPress={onCreateDeck}
|
||||
variant="card"
|
||||
width={deckWidth}
|
||||
title="Neues Deck erstellen"
|
||||
buttonText="Neues Deck erstellen"
|
||||
icon="library-add"
|
||||
buttonIcon="library-add"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (decks.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, styles.emptyState]}>
|
||||
<MaterialIcons name="dashboard" size={48} color={theme.colors.textTertiary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
No decks yet
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtext, { color: theme.colors.textTertiary }]}>
|
||||
Create your first deck to get started
|
||||
</Text>
|
||||
<CreateItemButton
|
||||
onPress={onCreateDeck}
|
||||
variant="button"
|
||||
title="Neues Deck erstellen"
|
||||
buttonText="Erstelle dein erstes Deck"
|
||||
icon="library-add"
|
||||
buttonIcon="library-add"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deckContainer,
|
||||
!isSmallScreen ? { width: deckWidth } : { },
|
||||
{ backgroundColor: 'transparent' }
|
||||
]}
|
||||
onPress={() => onDeckPress(item)}
|
||||
>
|
||||
<View style={styles.deckContent}>
|
||||
<MenuRoot>
|
||||
<MenuTrigger>
|
||||
<View style={styles.deckInfoContainer}>
|
||||
<View style={styles.metaInfo}>
|
||||
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
|
||||
{new Date(item.updatedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
<View style={[styles.separator, { backgroundColor: theme.colors.borderPrimary }]} />
|
||||
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
|
||||
{slideCounts[item.id] || 0} {slideCounts[item.id] === 1 ? 'Slide' : 'Slides'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}>
|
||||
{firstSlideImages[item.id] ? (
|
||||
<Image
|
||||
source={{ uri: firstSlideImages[item.id] }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[
|
||||
styles.deckHeader,
|
||||
!isSmallScreen && styles.horizontalDeckHeader
|
||||
]}>
|
||||
<View style={[
|
||||
styles.titleContainer,
|
||||
!isSmallScreen && styles.horizontalTitleContainer
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.deckTitle,
|
||||
{ color: theme.colors.textPrimary },
|
||||
!isSmallScreen && { textAlign: 'center' }
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title || item.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<View style={[
|
||||
styles.menuContent,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
}
|
||||
]}>
|
||||
<MenuItem
|
||||
onSelect={() => onShareDeck(item)}
|
||||
textValue="Share"
|
||||
>
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="share"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.menuItemTitle,
|
||||
{ color: theme.colors.textPrimary }
|
||||
]}>
|
||||
Share
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => onDeleteDeck(item)}
|
||||
textValue="Delete"
|
||||
>
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundError
|
||||
: theme.colors.backgroundPrimary
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={18}
|
||||
color={theme.colors.error}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.menuItemTitle,
|
||||
{ color: theme.colors.error }
|
||||
]}>
|
||||
Delete
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
</View>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
const renderDeck = ({ item }: { item: Deck | 'create' }) => {
|
||||
if (item === 'create') {
|
||||
return (
|
||||
<CreateItemButton
|
||||
onPress={onCreateDeck}
|
||||
variant="card"
|
||||
width={deckWidth}
|
||||
title="Neues Deck erstellen"
|
||||
buttonText="Neues Deck erstellen"
|
||||
icon="library-add"
|
||||
buttonIcon="library-add"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
style={[styles.list, { backgroundColor: 'transparent' }]}
|
||||
data={[...decks, 'create']}
|
||||
renderItem={renderDeck}
|
||||
keyExtractor={(item) => item === 'create' ? 'create' : item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
horizontal={!isSmallScreen}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: !isSmallScreen ? scrollPadding.horizontal.top : scrollPadding.vertical.top,
|
||||
paddingBottom: !isSmallScreen ? scrollPadding.horizontal.bottom : scrollPadding.vertical.bottom
|
||||
},
|
||||
!isSmallScreen && styles.horizontalListContent,
|
||||
]}
|
||||
scrollIndicatorInsets={{
|
||||
top: !isSmallScreen ? scrollPadding.horizontal.top : scrollPadding.vertical.top,
|
||||
bottom: !isSmallScreen ? scrollPadding.horizontal.bottom : scrollPadding.vertical.bottom
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={{
|
||||
height: deckSpacing.vertical,
|
||||
width: deckSpacing.horizontal,
|
||||
}} />
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deckContainer,
|
||||
!isSmallScreen ? { width: deckWidth } : {},
|
||||
{ backgroundColor: 'transparent' },
|
||||
]}
|
||||
onPress={() => onDeckPress(item)}
|
||||
>
|
||||
<View style={styles.deckContent}>
|
||||
<MenuRoot>
|
||||
<MenuTrigger>
|
||||
<View style={styles.deckInfoContainer}>
|
||||
<View style={styles.metaInfo}>
|
||||
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
|
||||
{new Date(item.updatedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.separator, { backgroundColor: theme.colors.borderPrimary }]}
|
||||
/>
|
||||
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
|
||||
{slideCounts[item.id] || 0} {slideCounts[item.id] === 1 ? 'Slide' : 'Slides'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
>
|
||||
{firstSlideImages[item.id] ? (
|
||||
<Image
|
||||
source={{ uri: firstSlideImages[item.id] }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.deckHeader, !isSmallScreen && styles.horizontalDeckHeader]}>
|
||||
<View
|
||||
style={[
|
||||
styles.titleContainer,
|
||||
!isSmallScreen && styles.horizontalTitleContainer,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.deckTitle,
|
||||
{ color: theme.colors.textPrimary },
|
||||
!isSmallScreen && { textAlign: 'center' },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title || item.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContent,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItem onSelect={() => onShareDeck(item)} textValue="Share">
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="share"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.textPrimary }]}>
|
||||
Share
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => onDeleteDeck(item)} textValue="Delete">
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundError
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={18}
|
||||
color={theme.colors.error}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.error }]}>
|
||||
Delete
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
</View>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
style={[styles.list, { backgroundColor: 'transparent' }]}
|
||||
data={[...decks, 'create']}
|
||||
renderItem={renderDeck}
|
||||
keyExtractor={(item) => (item === 'create' ? 'create' : item.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
horizontal={!isSmallScreen}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: !isSmallScreen ? scrollPadding.horizontal.top : scrollPadding.vertical.top,
|
||||
paddingBottom: !isSmallScreen
|
||||
? scrollPadding.horizontal.bottom
|
||||
: scrollPadding.vertical.bottom,
|
||||
},
|
||||
!isSmallScreen && styles.horizontalListContent,
|
||||
]}
|
||||
scrollIndicatorInsets={{
|
||||
top: !isSmallScreen ? scrollPadding.horizontal.top : scrollPadding.vertical.top,
|
||||
bottom: !isSmallScreen ? scrollPadding.horizontal.bottom : scrollPadding.vertical.bottom,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
height: deckSpacing.vertical,
|
||||
width: deckSpacing.horizontal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 8,
|
||||
gap: 16,
|
||||
},
|
||||
horizontalListContent: {
|
||||
padding: 8,
|
||||
gap: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
deckContainer: {
|
||||
},
|
||||
deckContent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
deckInfoContainer: {
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 4,
|
||||
gap: 8,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
textAlign: 'right',
|
||||
},
|
||||
separator: {
|
||||
width: 1,
|
||||
height: 12,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
deckHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
horizontalDeckHeader: {
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
horizontalTitleContainer: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 16,
|
||||
},
|
||||
horizontalActionsContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 'auto',
|
||||
},
|
||||
deckTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
createDeckContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
createDeckText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyState: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
},
|
||||
createButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
createButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
menuContent: {
|
||||
minWidth: 180,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 8,
|
||||
gap: 16,
|
||||
},
|
||||
horizontalListContent: {
|
||||
padding: 8,
|
||||
gap: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
deckContainer: {},
|
||||
deckContent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
deckInfoContainer: {
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 4,
|
||||
gap: 8,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
textAlign: 'right',
|
||||
},
|
||||
separator: {
|
||||
width: 1,
|
||||
height: 12,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
deckHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
horizontalDeckHeader: {
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
horizontalTitleContainer: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 16,
|
||||
},
|
||||
horizontalActionsContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 'auto',
|
||||
},
|
||||
deckTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
createDeckContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
createDeckText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyState: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
},
|
||||
createButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
createButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
menuContent: {
|
||||
minWidth: 180,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,271 +1,271 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
TextInput,
|
||||
Platform,
|
||||
Clipboard,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
TextInput,
|
||||
Platform,
|
||||
Clipboard,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Deck, CollaboratorRole } from '../../types/models';
|
||||
|
||||
interface DeckShareSettingsProps {
|
||||
deck: Deck;
|
||||
onUpdateSharing: (sharing: Deck['sharing']) => void;
|
||||
onClose: () => void;
|
||||
deck: Deck;
|
||||
onUpdateSharing: (sharing: Deck['sharing']) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CollaboratorInput {
|
||||
email: string;
|
||||
role: CollaboratorRole;
|
||||
email: string;
|
||||
role: CollaboratorRole;
|
||||
}
|
||||
|
||||
export const DeckShareSettings: React.FC<DeckShareSettingsProps> = ({
|
||||
deck,
|
||||
onUpdateSharing,
|
||||
onClose,
|
||||
deck,
|
||||
onUpdateSharing,
|
||||
onClose,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [isPublic, setIsPublic] = useState(deck.sharing.isPublic);
|
||||
const [newCollaborator, setNewCollaborator] = useState<CollaboratorInput>({
|
||||
email: '',
|
||||
role: 'viewer',
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const [isPublic, setIsPublic] = useState(deck.sharing.isPublic);
|
||||
const [newCollaborator, setNewCollaborator] = useState<CollaboratorInput>({
|
||||
email: '',
|
||||
role: 'viewer',
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shareUrl = `${Platform.OS === 'web' ? window.location.origin : 'https://presi.app'}/deck/${deck.id}`;
|
||||
const shareUrl = `${Platform.OS === 'web' ? window.location.origin : 'https://presi.app'}/deck/${deck.id}`;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
Clipboard.setString(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
const handleCopyLink = () => {
|
||||
Clipboard.setString(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleTogglePublic = () => {
|
||||
setIsPublic(!isPublic);
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
isPublic: !isPublic,
|
||||
});
|
||||
};
|
||||
const handleTogglePublic = () => {
|
||||
setIsPublic(!isPublic);
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
isPublic: !isPublic,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCollaborator = () => {
|
||||
// TODO: Implement email to userId lookup
|
||||
const mockUserId = 'user_' + Date.now();
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
collaborators: {
|
||||
...deck.sharing.collaborators,
|
||||
[mockUserId]: newCollaborator.role,
|
||||
},
|
||||
});
|
||||
setNewCollaborator({ email: '', role: 'viewer' });
|
||||
};
|
||||
const handleAddCollaborator = () => {
|
||||
// TODO: Implement email to userId lookup
|
||||
const mockUserId = 'user_' + Date.now();
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
collaborators: {
|
||||
...deck.sharing.collaborators,
|
||||
[mockUserId]: newCollaborator.role,
|
||||
},
|
||||
});
|
||||
setNewCollaborator({ email: '', role: 'viewer' });
|
||||
};
|
||||
|
||||
const handleRemoveCollaborator = (userId: string) => {
|
||||
const newCollaborators = { ...deck.sharing.collaborators };
|
||||
delete newCollaborators[userId];
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
collaborators: newCollaborators,
|
||||
});
|
||||
};
|
||||
const handleRemoveCollaborator = (userId: string) => {
|
||||
const newCollaborators = { ...deck.sharing.collaborators };
|
||||
delete newCollaborators[userId];
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
collaborators: newCollaborators,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Share Settings</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Share Settings</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={[styles.settingTitle, { color: theme.colors.textPrimary }]}>Public Access</Text>
|
||||
<Text style={[styles.settingDescription, { color: theme.colors.textSecondary }]}>
|
||||
Anyone with the link can view this deck
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={handleTogglePublic}
|
||||
trackColor={{ false: theme.colors.backgroundSecondary, true: theme.colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={[styles.settingTitle, { color: theme.colors.textPrimary }]}>
|
||||
Public Access
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: theme.colors.textSecondary }]}>
|
||||
Anyone with the link can view this deck
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={handleTogglePublic}
|
||||
trackColor={{ false: theme.colors.backgroundSecondary, true: theme.colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, styles.linkSection]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>Share Link</Text>
|
||||
<View style={[styles.linkContainer, { backgroundColor: theme.colors.backgroundSecondary }]}>
|
||||
<Text style={[styles.link, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{shareUrl}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.copyButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleCopyLink}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={copied ? "check" : "content-copy"}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.copyButtonText}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.section, styles.linkSection]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>
|
||||
Share Link
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.linkContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<Text style={[styles.link, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{shareUrl}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.copyButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleCopyLink}
|
||||
>
|
||||
<MaterialIcons name={copied ? 'check' : 'content-copy'} size={20} color="#FFFFFF" />
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Collaborators
|
||||
</Text>
|
||||
<View style={styles.collaboratorInput}>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.colors.textPrimary }]}
|
||||
placeholder="Email address"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
value={newCollaborator.email}
|
||||
onChangeText={(email) => setNewCollaborator({ ...newCollaborator, email })}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleAddCollaborator}
|
||||
>
|
||||
<Text style={styles.addButtonText}>Add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Collaborators
|
||||
</Text>
|
||||
<View style={styles.collaboratorInput}>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.colors.textPrimary }]}
|
||||
placeholder="Email address"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
value={newCollaborator.email}
|
||||
onChangeText={(email) => setNewCollaborator({ ...newCollaborator, email })}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleAddCollaborator}
|
||||
>
|
||||
<Text style={styles.addButtonText}>Add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{Object.entries(deck.sharing.collaborators).map(([userId, role]) => (
|
||||
<View key={userId} style={styles.collaboratorRow}>
|
||||
<Text style={[styles.collaboratorEmail, { color: theme.colors.textPrimary }]}>
|
||||
{userId} ({role})
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => handleRemoveCollaborator(userId)}>
|
||||
<MaterialIcons name="remove-circle" size={24} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
{Object.entries(deck.sharing.collaborators).map(([userId, role]) => (
|
||||
<View key={userId} style={styles.collaboratorRow}>
|
||||
<Text style={[styles.collaboratorEmail, { color: theme.colors.textPrimary }]}>
|
||||
{userId} ({role})
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => handleRemoveCollaborator(userId)}>
|
||||
<MaterialIcons name="remove-circle" size={24} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#ccc',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
gap: 16,
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingInfo: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
linkSection: {
|
||||
gap: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
linkContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
gap: 8,
|
||||
},
|
||||
link: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
gap: 4,
|
||||
},
|
||||
copyButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
collaboratorInput: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
addButton: {
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
addButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
collaboratorRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
collaboratorEmail: {
|
||||
fontSize: 16,
|
||||
},
|
||||
container: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#ccc',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
gap: 16,
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingInfo: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
linkSection: {
|
||||
gap: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
linkContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
gap: 8,
|
||||
},
|
||||
link: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
gap: 4,
|
||||
},
|
||||
copyButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
collaboratorInput: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
addButton: {
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
addButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
collaboratorRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
collaboratorEmail: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import { createDeck } from '../../services/firestore';
|
||||
|
|
@ -14,172 +14,171 @@ import { Deck } from '../../types/models';
|
|||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface CreateDeckFormProps {
|
||||
onSuccess: (deck: Deck) => void;
|
||||
onCancel: () => void;
|
||||
onSuccess: (deck: Deck) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreateDeckForm: React.FC<CreateDeckFormProps> = ({
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
export const CreateDeckForm: React.FC<CreateDeckFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Please enter a name for your deck');
|
||||
return;
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Please enter a name for your deck');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newDeck = await createDeck({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
onSuccess(newDeck);
|
||||
} catch (err) {
|
||||
setError('Failed to create deck. Please try again.');
|
||||
console.error('Error creating deck:', err);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const newDeck = await createDeck({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
onSuccess(newDeck);
|
||||
} catch (err) {
|
||||
setError('Failed to create deck. Please try again.');
|
||||
console.error('Error creating deck:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create New Deck</Text>
|
||||
<TouchableOpacity onPress={onCancel} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create New Deck</Text>
|
||||
<TouchableOpacity onPress={onCancel} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Name</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border
|
||||
}
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Enter deck name"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Name</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Enter deck name"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Description (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border
|
||||
}
|
||||
]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Enter deck description"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
|
||||
Description (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Enter deck description"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<Text style={[styles.error, { color: theme.colors.error }]}>{error}</Text>
|
||||
)}
|
||||
{error && <Text style={[styles.error, { color: theme.colors.error }]}>{error}</Text>}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: '#FFFFFF' }]}>Create Deck</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.cancelButton,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: '#FFFFFF' }]}>Create Deck</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
error: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cancelButton: {
|
||||
},
|
||||
createButton: {
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
error: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cancelButton: {},
|
||||
createButton: {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
Image,
|
||||
Animated,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
Image,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SlideView } from '../slides/SlideView';
|
||||
|
|
@ -16,311 +16,304 @@ import * as ScreenOrientation from 'expo-screen-orientation';
|
|||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface PresentationModeProps {
|
||||
slides: Slide[];
|
||||
initialSlideIndex?: number;
|
||||
onClose?: () => void;
|
||||
slides: Slide[];
|
||||
initialSlideIndex?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const PresentationMode: React.FC<PresentationModeProps> = ({
|
||||
slides,
|
||||
initialSlideIndex = 0,
|
||||
onClose,
|
||||
slides,
|
||||
initialSlideIndex = 0,
|
||||
onClose,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialSlideIndex);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [showSpeakerNotes, setShowSpeakerNotes] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
const { theme } = useTheme();
|
||||
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialSlideIndex);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [showSpeakerNotes, setShowSpeakerNotes] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
// Control visibility state
|
||||
const controlsOpacity = useRef(new Animated.Value(1)).current;
|
||||
const hideControlsTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
// Control visibility state
|
||||
const controlsOpacity = useRef(new Animated.Value(1)).current;
|
||||
const hideControlsTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
// Clear any existing timer
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
const showControls = useCallback(() => {
|
||||
// Clear any existing timer
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
|
||||
// Show controls with animation
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
// Show controls with animation
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
// Set timer to hide controls after 5 seconds
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, 5000);
|
||||
}, [controlsOpacity]);
|
||||
// Set timer to hide controls after 5 seconds
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, 5000);
|
||||
}, [controlsOpacity]);
|
||||
|
||||
const handleNavigation = useCallback((direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev') {
|
||||
setCurrentSlideIndex(prev => prev === 0 ? slides.length - 1 : prev - 1);
|
||||
} else {
|
||||
setCurrentSlideIndex(prev => prev === slides.length - 1 ? 0 : prev + 1);
|
||||
}
|
||||
showControls();
|
||||
}, [slides.length, showControls]);
|
||||
const handleNavigation = useCallback(
|
||||
(direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev') {
|
||||
setCurrentSlideIndex((prev) => (prev === 0 ? slides.length - 1 : prev - 1));
|
||||
} else {
|
||||
setCurrentSlideIndex((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
|
||||
}
|
||||
showControls();
|
||||
},
|
||||
[slides.length, showControls]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
console.log('Key pressed:', event.key); // Debug log
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'arrowleft':
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
handleNavigation('prev');
|
||||
break;
|
||||
case 'arrowright':
|
||||
case 'd':
|
||||
event.preventDefault();
|
||||
handleNavigation('next');
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
console.log('Key pressed:', event.key); // Debug log
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'arrowleft':
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
handleNavigation('prev');
|
||||
break;
|
||||
case 'arrowright':
|
||||
case 'd':
|
||||
event.preventDefault();
|
||||
handleNavigation('next');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleNavigation]);
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleNavigation]);
|
||||
|
||||
// Handle mouse movement
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('mousemove', showControls);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', showControls);
|
||||
};
|
||||
}
|
||||
}, [showControls]);
|
||||
// Handle mouse movement
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('mousemove', showControls);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', showControls);
|
||||
};
|
||||
}
|
||||
}, [showControls]);
|
||||
|
||||
// Show controls initially
|
||||
useEffect(() => {
|
||||
showControls();
|
||||
}, []);
|
||||
// Show controls initially
|
||||
useEffect(() => {
|
||||
showControls();
|
||||
}, []);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isTimerRunning) {
|
||||
timer = setInterval(() => {
|
||||
setElapsedTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [isTimerRunning]);
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isTimerRunning) {
|
||||
timer = setInterval(() => {
|
||||
setElapsedTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [isTimerRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
const setupOrientation = async () => {
|
||||
if (isFullscreen) {
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE
|
||||
);
|
||||
} else {
|
||||
await ScreenOrientation.unlockAsync();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const setupOrientation = async () => {
|
||||
if (isFullscreen) {
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||
} else {
|
||||
await ScreenOrientation.unlockAsync();
|
||||
}
|
||||
};
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
setupOrientation();
|
||||
}
|
||||
if (Platform.OS !== 'web') {
|
||||
setupOrientation();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
ScreenOrientation.unlockAsync();
|
||||
}
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
return () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
ScreenOrientation.unlockAsync();
|
||||
}
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
|
||||
const currentSlide = slides[currentSlideIndex];
|
||||
const currentSlide = slides[currentSlideIndex];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
{/* Top bar with title and close button */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.topBar,
|
||||
{
|
||||
opacity: controlsOpacity,
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.slideTitle, { color: theme.colors.textPrimary }]}>
|
||||
{currentSlide.title}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.closeButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
{/* Top bar with title and close button */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.topBar,
|
||||
{
|
||||
opacity: controlsOpacity,
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.slideTitle, { color: theme.colors.textPrimary }]}>
|
||||
{currentSlide.title}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.closeButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Current slide */}
|
||||
<View style={styles.slideContainer}>
|
||||
<SlideView
|
||||
slide={currentSlide}
|
||||
showNotes={showSpeakerNotes}
|
||||
isFullscreen={isFullscreen}
|
||||
onNavigate={handleNavigation}
|
||||
isFirstSlide={currentSlideIndex === 0}
|
||||
isLastSlide={currentSlideIndex === slides.length - 1}
|
||||
/>
|
||||
</View>
|
||||
{/* Current slide */}
|
||||
<View style={styles.slideContainer}>
|
||||
<SlideView
|
||||
slide={currentSlide}
|
||||
showNotes={showSpeakerNotes}
|
||||
isFullscreen={isFullscreen}
|
||||
onNavigate={handleNavigation}
|
||||
isFirstSlide={currentSlideIndex === 0}
|
||||
isLastSlide={currentSlideIndex === slides.length - 1}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Controls overlay with animation */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.controlsOverlay,
|
||||
{
|
||||
opacity: controlsOpacity,
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => handleNavigation('prev')}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{/* Controls overlay with animation */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.controlsOverlay,
|
||||
{
|
||||
opacity: controlsOpacity,
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => handleNavigation('prev')}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.centerControls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setShowSpeakerNotes(!showSpeakerNotes)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showSpeakerNotes ? 'eye-off' : 'eye'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.centerControls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setShowSpeakerNotes(!showSpeakerNotes)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showSpeakerNotes ? 'eye-off' : 'eye'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setIsTimerRunning(!isTimerRunning)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isTimerRunning ? 'pause' : 'play'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setIsTimerRunning(!isTimerRunning)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isTimerRunning ? 'pause' : 'play'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.timer, { color: theme.colors.textPrimary }]}>
|
||||
{Math.floor(elapsedTime / 60)}:{(elapsedTime % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
<Text style={[styles.timer, { color: theme.colors.textPrimary }]}>
|
||||
{Math.floor(elapsedTime / 60)}:{(elapsedTime % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isFullscreen ? 'contract' : 'expand'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isFullscreen ? 'contract' : 'expand'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => handleNavigation('next')}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => handleNavigation('next')}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 40,
|
||||
zIndex: 10,
|
||||
},
|
||||
slideTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
slideContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlsOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
maxWidth: 800,
|
||||
marginHorizontal: 'auto',
|
||||
},
|
||||
centerControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
controlButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
timer: {
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 40,
|
||||
zIndex: 10,
|
||||
},
|
||||
slideTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
slideContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlsOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
maxWidth: 800,
|
||||
marginHorizontal: 'auto',
|
||||
},
|
||||
centerControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
controlButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
timer: {
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Image
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { storage } from '../../firebaseConfig';
|
||||
|
|
@ -18,353 +18,319 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface SlideEditorProps {
|
||||
deckId: string;
|
||||
slide?: Slide;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
deckId: string;
|
||||
slide?: Slide;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const SlideEditor: React.FC<SlideEditorProps> = ({
|
||||
deckId,
|
||||
slide,
|
||||
onSuccess,
|
||||
onCancel
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [title, setTitle] = useState(slide?.title ?? '');
|
||||
const [fullText, setFullText] = useState(slide?.fullText ?? '');
|
||||
const [bulletPoints, setBulletPoints] = useState<string[]>(
|
||||
slide?.bulletPoints ?? ['']
|
||||
);
|
||||
const [notes, setNotes] = useState(slide?.notes ?? '');
|
||||
const [imageUrl, setImageUrl] = useState(slide?.imageUrl);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
export const SlideEditor: React.FC<SlideEditorProps> = ({ deckId, slide, onSuccess, onCancel }) => {
|
||||
const { theme } = useTheme();
|
||||
const [title, setTitle] = useState(slide?.title ?? '');
|
||||
const [fullText, setFullText] = useState(slide?.fullText ?? '');
|
||||
const [bulletPoints, setBulletPoints] = useState<string[]>(slide?.bulletPoints ?? ['']);
|
||||
const [notes, setNotes] = useState(slide?.notes ?? '');
|
||||
const [imageUrl, setImageUrl] = useState(slide?.imageUrl);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleAddBulletPoint = () => {
|
||||
setBulletPoints([...bulletPoints, '']);
|
||||
};
|
||||
const handleAddBulletPoint = () => {
|
||||
setBulletPoints([...bulletPoints, '']);
|
||||
};
|
||||
|
||||
const handleUpdateBulletPoint = (index: number, text: string) => {
|
||||
const newBulletPoints = [...bulletPoints];
|
||||
newBulletPoints[index] = text;
|
||||
setBulletPoints(newBulletPoints);
|
||||
};
|
||||
const handleUpdateBulletPoint = (index: number, text: string) => {
|
||||
const newBulletPoints = [...bulletPoints];
|
||||
newBulletPoints[index] = text;
|
||||
setBulletPoints(newBulletPoints);
|
||||
};
|
||||
|
||||
const handleRemoveBulletPoint = (index: number) => {
|
||||
const newBulletPoints = bulletPoints.filter((_, i) => i !== index);
|
||||
setBulletPoints(newBulletPoints);
|
||||
};
|
||||
const handleRemoveBulletPoint = (index: number) => {
|
||||
const newBulletPoints = bulletPoints.filter((_, i) => i !== index);
|
||||
setBulletPoints(newBulletPoints);
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (permissionResult.granted === false) {
|
||||
Alert.alert('Permission Required', 'Please allow access to your photos to upload images.');
|
||||
return;
|
||||
}
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 1,
|
||||
});
|
||||
if (permissionResult.granted === false) {
|
||||
Alert.alert('Permission Required', 'Please allow access to your photos to upload images.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.canceled) {
|
||||
const uri = result.assets[0].uri;
|
||||
setImageUrl(uri);
|
||||
}
|
||||
};
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let uploadedImageUrl = imageUrl;
|
||||
|
||||
// Upload image if selected
|
||||
if (imageUrl && imageUrl.startsWith('file://')) {
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const imagePath = `slides/${deckId}/${Date.now()}.jpg`;
|
||||
const imageRef = ref(storage, imagePath);
|
||||
await uploadBytes(imageRef, blob);
|
||||
uploadedImageUrl = await getDownloadURL(imageRef);
|
||||
}
|
||||
if (!result.canceled) {
|
||||
const uri = result.assets[0].uri;
|
||||
setImageUrl(uri);
|
||||
}
|
||||
};
|
||||
|
||||
// Create or update slide
|
||||
const slideData = {
|
||||
deckId,
|
||||
title,
|
||||
fullText,
|
||||
bulletPoints: bulletPoints.filter(bp => bp.trim() !== ''),
|
||||
notes,
|
||||
imageUrl: uploadedImageUrl,
|
||||
};
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (slide?.id) {
|
||||
// Update existing slide
|
||||
await updateSlide(slide.id, slideData);
|
||||
} else {
|
||||
// Create new slide
|
||||
const newSlide = await createSlide(slideData);
|
||||
console.log('[SlideEditor] Slide created successfully:', newSlide);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('[SlideEditor] Error saving slide:', error);
|
||||
Alert.alert('Error', 'Failed to save slide. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let uploadedImageUrl = imageUrl;
|
||||
|
||||
return (
|
||||
<View style={[styles.editorContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>Title</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.borderPrimary
|
||||
}
|
||||
]}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Enter slide title"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
// Upload image if selected
|
||||
if (imageUrl && imageUrl.startsWith('file://')) {
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const imagePath = `slides/${deckId}/${Date.now()}.jpg`;
|
||||
const imageRef = ref(storage, imagePath);
|
||||
await uploadBytes(imageRef, blob);
|
||||
uploadedImageUrl = await getDownloadURL(imageRef);
|
||||
}
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Image</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.imagePreview,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.border
|
||||
}
|
||||
]}
|
||||
onPress={handlePickImage}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.imagePreview}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<MaterialIcons
|
||||
name="add-photo-alternate"
|
||||
size={32}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<Text style={[styles.addButtonText, { color: theme.colors.primary }]}>
|
||||
Add Image
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
// Create or update slide
|
||||
const slideData = {
|
||||
deckId,
|
||||
title,
|
||||
fullText,
|
||||
bulletPoints: bulletPoints.filter((bp) => bp.trim() !== ''),
|
||||
notes,
|
||||
imageUrl: uploadedImageUrl,
|
||||
};
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Full Text</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border
|
||||
}
|
||||
]}
|
||||
value={fullText}
|
||||
onChangeText={setFullText}
|
||||
placeholder="Enter full text content..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
if (slide?.id) {
|
||||
// Update existing slide
|
||||
await updateSlide(slide.id, slideData);
|
||||
} else {
|
||||
// Create new slide
|
||||
const newSlide = await createSlide(slideData);
|
||||
console.log('[SlideEditor] Slide created successfully:', newSlide);
|
||||
}
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Bullet Points</Text>
|
||||
{bulletPoints.map((point, index) => (
|
||||
<View key={index} style={styles.bulletPointContainer}>
|
||||
<MaterialIcons
|
||||
name="circle"
|
||||
size={8}
|
||||
color={theme.colors.textSecondary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.bulletPointInput,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border
|
||||
}
|
||||
]}
|
||||
value={point}
|
||||
onChangeText={(text) => handleUpdateBulletPoint(index, text)}
|
||||
placeholder="Add bullet point..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={{ padding: 4 }}
|
||||
onPress={() => handleRemoveBulletPoint(index)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="remove-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.error}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={handleAddBulletPoint}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="add-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<Text style={[styles.addButtonText, { color: theme.colors.primary }]}>
|
||||
Add Bullet Point
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('[SlideEditor] Error saving slide:', error);
|
||||
Alert.alert('Error', 'Failed to save slide. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>Notes</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.borderPrimary
|
||||
}
|
||||
]}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Add presenter notes"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
return (
|
||||
<View style={[styles.editorContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>Title</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Enter slide title"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
{ backgroundColor: theme.colors.primary }
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textOnPrimary }]}>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Image</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.imagePreview,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={handlePickImage}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image source={{ uri: imageUrl }} style={styles.imagePreview} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<MaterialIcons name="add-photo-alternate" size={32} color={theme.colors.primary} />
|
||||
<Text style={[styles.addButtonText, { color: theme.colors.primary }]}>
|
||||
Add Image
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Full Text</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={fullText}
|
||||
onChangeText={setFullText}
|
||||
placeholder="Enter full text content..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Bullet Points</Text>
|
||||
{bulletPoints.map((point, index) => (
|
||||
<View key={index} style={styles.bulletPointContainer}>
|
||||
<MaterialIcons
|
||||
name="circle"
|
||||
size={8}
|
||||
color={theme.colors.textSecondary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.bulletPointInput,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={point}
|
||||
onChangeText={(text) => handleUpdateBulletPoint(index, text)}
|
||||
placeholder="Add bullet point..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={{ padding: 4 }}
|
||||
onPress={() => handleRemoveBulletPoint(index)}
|
||||
>
|
||||
<MaterialIcons name="remove-circle-outline" size={20} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleAddBulletPoint}>
|
||||
<MaterialIcons name="add-circle-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.addButtonText, { color: theme.colors.primary }]}>
|
||||
Add Bullet Point
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>Notes</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Add presenter notes"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textOnPrimary }]}>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
editorContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
bulletPointContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
bulletPointInput: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
addButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
imagePreview: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
}
|
||||
});
|
||||
editorContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
bulletPointContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
bulletPointInput: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
addButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
imagePreview: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Image,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Image,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Slide } from '../../types/models';
|
||||
|
|
@ -16,414 +16,411 @@ import { useTheme } from '../ThemeProvider';
|
|||
import { MenuRoot, MenuTrigger, MenuContent, MenuItem, MenuItemTitle } from '../common/menu';
|
||||
|
||||
interface SlideListProps {
|
||||
slides: Slide[];
|
||||
showNotes?: boolean;
|
||||
onEditSlide?: (slide: Slide) => void;
|
||||
onCreateSlide?: () => void;
|
||||
onDeleteSlide?: (slide: Slide) => void;
|
||||
onMoveSlide?: (slide: Slide, direction: 'up' | 'down') => void;
|
||||
loading?: boolean;
|
||||
slides: Slide[];
|
||||
showNotes?: boolean;
|
||||
onEditSlide?: (slide: Slide) => void;
|
||||
onCreateSlide?: () => void;
|
||||
onDeleteSlide?: (slide: Slide) => void;
|
||||
onMoveSlide?: (slide: Slide, direction: 'up' | 'down') => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SlideList: React.FC<SlideListProps> = ({
|
||||
slides,
|
||||
showNotes = false,
|
||||
onEditSlide,
|
||||
onCreateSlide,
|
||||
onDeleteSlide,
|
||||
onMoveSlide,
|
||||
loading,
|
||||
export const SlideList: React.FC<SlideListProps> = ({
|
||||
slides,
|
||||
showNotes = false,
|
||||
onEditSlide,
|
||||
onCreateSlide,
|
||||
onDeleteSlide,
|
||||
onMoveSlide,
|
||||
loading,
|
||||
}) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { theme } = useTheme();
|
||||
const isSmallScreen = width < 768; // Tablet breakpoint
|
||||
|
||||
// Calculate slide width to show 2.5 slides
|
||||
const slideWidth = !isSmallScreen ? Math.floor(width / 2.5) : 'auto';
|
||||
const { width } = useWindowDimensions();
|
||||
const { theme } = useTheme();
|
||||
const isSmallScreen = width < 768; // Tablet breakpoint
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.emptyState, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<MaterialIcons name="hourglass-empty" size={48} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Calculate slide width to show 2.5 slides
|
||||
const slideWidth = !isSmallScreen ? Math.floor(width / 2.5) : 'auto';
|
||||
|
||||
const renderItem = ({ item, index }: { item: Slide | 'create'; index: number }) => {
|
||||
if (item === 'create') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth
|
||||
}
|
||||
]}
|
||||
onPress={onCreateSlide}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="add-photo-alternate" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
Neuen Slide erstellen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.emptyState, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<MaterialIcons name="hourglass-empty" size={48} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MenuRoot>
|
||||
<MenuTrigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => onEditSlide?.(item)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}>
|
||||
{item.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
{item.title || `Slide ${index + 1}`}
|
||||
</Text>
|
||||
</View>
|
||||
{showNotes && item.notes && (
|
||||
<Text style={[styles.notes, { color: theme.colors.textSecondary }]} numberOfLines={2}>
|
||||
{item.notes}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<View style={[
|
||||
styles.menuContent,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
}
|
||||
]}>
|
||||
{onMoveSlide && index > 0 && (
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
console.log('[SlideList] Selected Move Up for slide:', item.id);
|
||||
onMoveSlide(item, 'up');
|
||||
}}
|
||||
textValue="Move Up"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log('[SlideList] Pressed Move Up for slide:', item.id);
|
||||
onMoveSlide(item, 'up');
|
||||
}}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.menuItemTitle,
|
||||
{ color: theme.colors.textPrimary }
|
||||
]}>
|
||||
Nach oben
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onMoveSlide && index < (slides.length - 1) && (
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
console.log('[SlideList] Selected Move Down for slide:', item.id);
|
||||
onMoveSlide(item, 'down');
|
||||
}}
|
||||
textValue="Move Down"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log('[SlideList] Pressed Move Down for slide:', item.id);
|
||||
onMoveSlide(item, 'down');
|
||||
}}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="arrow-downward"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.menuItemTitle,
|
||||
{ color: theme.colors.textPrimary }
|
||||
]}>
|
||||
Nach unten
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDeleteSlide && (
|
||||
<MenuItem
|
||||
onSelect={() => onDeleteSlide(item)}
|
||||
textValue="Delete"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => onDeleteSlide(item)}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundError
|
||||
: theme.colors.backgroundPrimary
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={18}
|
||||
color={theme.colors.error}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.menuItemTitle,
|
||||
{ color: theme.colors.error }
|
||||
]}>
|
||||
Löschen
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
</View>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const renderItem = ({ item, index }: { item: Slide | 'create'; index: number }) => {
|
||||
if (item === 'create') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth,
|
||||
},
|
||||
]}
|
||||
onPress={onCreateSlide}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons
|
||||
name="add-photo-alternate"
|
||||
size={48}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
Neuen Slide erstellen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (slides.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyState}>
|
||||
<MaterialIcons name="slideshow" size={48} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
Keine Slides
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtext, { color: theme.colors.textSecondary }]}>
|
||||
Erstelle deinen ersten Slide
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth
|
||||
}
|
||||
]}
|
||||
onPress={onCreateSlide}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary }
|
||||
]}>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="add-photo-alternate" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
Erstelle deinen ersten Slide
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuRoot>
|
||||
<MenuTrigger>
|
||||
<TouchableOpacity onPress={() => onEditSlide?.(item)} style={{ flex: 1 }}>
|
||||
<View style={styles.slideContent}>
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
>
|
||||
{item.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
{item.title || `Slide ${index + 1}`}
|
||||
</Text>
|
||||
</View>
|
||||
{showNotes && item.notes && (
|
||||
<Text
|
||||
style={[styles.notes, { color: theme.colors.textSecondary }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.notes}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContent,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{onMoveSlide && index > 0 && (
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
console.log('[SlideList] Selected Move Up for slide:', item.id);
|
||||
onMoveSlide(item, 'up');
|
||||
}}
|
||||
textValue="Move Up"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log('[SlideList] Pressed Move Up for slide:', item.id);
|
||||
onMoveSlide(item, 'up');
|
||||
}}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.textPrimary }]}>
|
||||
Nach oben
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onMoveSlide && index < slides.length - 1 && (
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
console.log('[SlideList] Selected Move Down for slide:', item.id);
|
||||
onMoveSlide(item, 'down');
|
||||
}}
|
||||
textValue="Move Down"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log('[SlideList] Pressed Move Down for slide:', item.id);
|
||||
onMoveSlide(item, 'down');
|
||||
}}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="arrow-downward"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.textPrimary }]}>
|
||||
Nach unten
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDeleteSlide && (
|
||||
<MenuItem onSelect={() => onDeleteSlide(item)} textValue="Delete">
|
||||
<Pressable
|
||||
onPress={() => onDeleteSlide(item)}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundError
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={18}
|
||||
color={theme.colors.error}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.error }]}>
|
||||
Löschen
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
</View>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={[...slides, 'create']}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item === 'create' ? 'create' : item.id}
|
||||
horizontal={!isSmallScreen}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
!isSmallScreen && { paddingHorizontal: 16 }
|
||||
]}
|
||||
extraData={slides.map(s => s.order).join(',')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
if (slides.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyState}>
|
||||
<MaterialIcons name="slideshow" size={48} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
Keine Slides
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtext, { color: theme.colors.textSecondary }]}>
|
||||
Erstelle deinen ersten Slide
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth,
|
||||
},
|
||||
]}
|
||||
onPress={onCreateSlide}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons
|
||||
name="add-photo-alternate"
|
||||
size={48}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
Erstelle deinen ersten Slide
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={[...slides, 'create']}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => (item === 'create' ? 'create' : item.id)}
|
||||
horizontal={!isSmallScreen}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.listContent, !isSmallScreen && { paddingHorizontal: 16 }]}
|
||||
extraData={slides.map((s) => s.order).join(',')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
horizontalListContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
minHeight: '100%',
|
||||
},
|
||||
slideContainer: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
},
|
||||
slideContent: {
|
||||
gap: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 16/9,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
slideFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 0,
|
||||
},
|
||||
slideNumber: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 32,
|
||||
},
|
||||
menuContent: {
|
||||
minWidth: 180,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
horizontalListContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
minHeight: '100%',
|
||||
},
|
||||
slideContainer: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
},
|
||||
slideContent: {
|
||||
gap: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
slideFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 0,
|
||||
},
|
||||
slideNumber: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 32,
|
||||
},
|
||||
menuContent: {
|
||||
minWidth: 180,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,126 +5,127 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
|||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface SlideViewProps {
|
||||
slide: Slide;
|
||||
showNotes?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
onNavigate?: (direction: 'prev' | 'next') => void;
|
||||
isFirstSlide?: boolean;
|
||||
isLastSlide?: boolean;
|
||||
slide: Slide;
|
||||
showNotes?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
onNavigate?: (direction: 'prev' | 'next') => void;
|
||||
isFirstSlide?: boolean;
|
||||
isLastSlide?: boolean;
|
||||
}
|
||||
|
||||
export const SlideView: React.FC<SlideViewProps> = ({
|
||||
slide,
|
||||
showNotes = false,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
onNavigate,
|
||||
isFirstSlide = false,
|
||||
isLastSlide = false
|
||||
export const SlideView: React.FC<SlideViewProps> = ({
|
||||
slide,
|
||||
showNotes = false,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
onNavigate,
|
||||
isFirstSlide = false,
|
||||
isLastSlide = false,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
isFullscreen && styles.fullscreenContainer,
|
||||
{
|
||||
backgroundColor: isFullscreen ? theme.colors.backgroundPage : theme.colors.backgroundPrimary
|
||||
}
|
||||
]}>
|
||||
{slide.imageUrl && (
|
||||
<View style={[
|
||||
styles.imageContainer,
|
||||
isFullscreen && styles.fullscreenImageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundPage }
|
||||
]}>
|
||||
{/* Navigation Areas */}
|
||||
{!isFirstSlide && (
|
||||
<TouchableOpacity
|
||||
style={styles.navigationArea}
|
||||
onPress={() => onNavigate?.('prev')}
|
||||
/>
|
||||
)}
|
||||
{!isLastSlide && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navigationArea, styles.navigationAreaRight]}
|
||||
onPress={() => onNavigate?.('next')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<Image
|
||||
source={{ uri: slide.imageUrl }}
|
||||
style={styles.image}
|
||||
resizeMode={isFullscreen ? "contain" : "cover"}
|
||||
/>
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isFullscreen && styles.fullscreenContainer,
|
||||
{
|
||||
backgroundColor: isFullscreen
|
||||
? theme.colors.backgroundPage
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{slide.imageUrl && (
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
isFullscreen && styles.fullscreenImageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundPage },
|
||||
]}
|
||||
>
|
||||
{/* Navigation Areas */}
|
||||
{!isFirstSlide && (
|
||||
<TouchableOpacity style={styles.navigationArea} onPress={() => onNavigate?.('prev')} />
|
||||
)}
|
||||
{!isLastSlide && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navigationArea, styles.navigationAreaRight]}
|
||||
onPress={() => onNavigate?.('next')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation Indicators */}
|
||||
{isFullscreen && (
|
||||
<>
|
||||
{!isFirstSlide && (
|
||||
<View style={[styles.navigationIndicator, styles.navigationIndicatorLeft]}>
|
||||
<MaterialIcons
|
||||
name="chevron-left"
|
||||
size={36}
|
||||
color={`${theme.colors.textPrimary}80`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!isLastSlide && (
|
||||
<View style={[styles.navigationIndicator, styles.navigationIndicatorRight]}>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={36}
|
||||
color={`${theme.colors.textPrimary}80`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isFullscreen && (
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.contentTitle, { color: theme.colors.textPrimary }]}>
|
||||
{slide.title}
|
||||
</Text>
|
||||
|
||||
{slide.bulletPoints && slide.bulletPoints.length > 0 && (
|
||||
<View style={styles.bulletPoints}>
|
||||
{slide.bulletPoints.map((point, index) => (
|
||||
<View key={index} style={styles.bulletPoint}>
|
||||
<Text style={[styles.bullet, { color: theme.colors.textPrimary }]}>•</Text>
|
||||
<Text style={[styles.bulletText, { color: theme.colors.textPrimary }]}>
|
||||
{point}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{/* Image */}
|
||||
<Image
|
||||
source={{ uri: slide.imageUrl }}
|
||||
style={styles.image}
|
||||
resizeMode={isFullscreen ? 'contain' : 'cover'}
|
||||
/>
|
||||
|
||||
{slide.fullText && (
|
||||
<Text style={[styles.fullText, { color: theme.colors.textPrimary }]}>
|
||||
{slide.fullText}
|
||||
</Text>
|
||||
)}
|
||||
{/* Navigation Indicators */}
|
||||
{isFullscreen && (
|
||||
<>
|
||||
{!isFirstSlide && (
|
||||
<View style={[styles.navigationIndicator, styles.navigationIndicatorLeft]}>
|
||||
<MaterialIcons
|
||||
name="chevron-left"
|
||||
size={36}
|
||||
color={`${theme.colors.textPrimary}80`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!isLastSlide && (
|
||||
<View style={[styles.navigationIndicator, styles.navigationIndicatorRight]}>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={36}
|
||||
color={`${theme.colors.textPrimary}80`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showNotes && slide.notes && (
|
||||
<View style={[styles.notesContainer, { backgroundColor: theme.colors.backgroundSecondary }]}>
|
||||
<Text style={[styles.notesTitle, { color: theme.colors.textSecondary }]}>
|
||||
Notes:
|
||||
</Text>
|
||||
<Text style={[styles.notes, { color: theme.colors.textPrimary }]}>
|
||||
{slide.notes}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
{!isFullscreen && (
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.contentTitle, { color: theme.colors.textPrimary }]}>
|
||||
{slide.title}
|
||||
</Text>
|
||||
|
||||
{slide.bulletPoints && slide.bulletPoints.length > 0 && (
|
||||
<View style={styles.bulletPoints}>
|
||||
{slide.bulletPoints.map((point, index) => (
|
||||
<View key={index} style={styles.bulletPoint}>
|
||||
<Text style={[styles.bullet, { color: theme.colors.textPrimary }]}>•</Text>
|
||||
<Text style={[styles.bulletText, { color: theme.colors.textPrimary }]}>
|
||||
{point}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{slide.fullText && (
|
||||
<Text style={[styles.fullText, { color: theme.colors.textPrimary }]}>
|
||||
{slide.fullText}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showNotes && slide.notes && (
|
||||
<View
|
||||
style={[styles.notesContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<Text style={[styles.notesTitle, { color: theme.colors.textSecondary }]}>Notes:</Text>
|
||||
<Text style={[styles.notes, { color: theme.colors.textPrimary }]}>{slide.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
|
@ -133,99 +134,99 @@ const SLIDE_WIDTH = width;
|
|||
const SLIDE_HEIGHT = SLIDE_WIDTH / ASPECT_RATIO;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: SLIDE_WIDTH,
|
||||
height: SLIDE_HEIGHT,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fullscreenContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 16/9,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
fullscreenImageContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
aspectRatio: undefined,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
navigationArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
zIndex: 2,
|
||||
},
|
||||
navigationAreaRight: {
|
||||
left: '50%',
|
||||
},
|
||||
navigationIndicator: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: [{ translateY: -18 }],
|
||||
opacity: 0.5,
|
||||
zIndex: 1,
|
||||
},
|
||||
navigationIndicatorLeft: {
|
||||
left: 16,
|
||||
},
|
||||
navigationIndicatorRight: {
|
||||
right: 16,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletPoints: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletPoint: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
bullet: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
bulletText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
fullText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
notesContainer: {
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
notesTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
width: SLIDE_WIDTH,
|
||||
height: SLIDE_HEIGHT,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fullscreenContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 16 / 9,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
fullscreenImageContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
aspectRatio: undefined,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
navigationArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
zIndex: 2,
|
||||
},
|
||||
navigationAreaRight: {
|
||||
left: '50%',
|
||||
},
|
||||
navigationIndicator: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: [{ translateY: -18 }],
|
||||
opacity: 0.5,
|
||||
zIndex: 1,
|
||||
},
|
||||
navigationIndicatorLeft: {
|
||||
left: 16,
|
||||
},
|
||||
navigationIndicatorRight: {
|
||||
right: 16,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletPoints: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletPoint: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
bullet: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
bulletText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
fullText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
notesContainer: {
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
notesTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,272 +4,272 @@ import { ColorSchemeName } from 'react-native';
|
|||
export type ThemeVariant = 'lume' | 'nature' | 'stone';
|
||||
|
||||
export const THEME_PATTERNS: Record<ThemeVariant, any> = {
|
||||
lume: require('../assets/images/patterns/memo-theme-tile.png'),
|
||||
nature: require('../assets/images/patterns/nature-theme-tile.png'),
|
||||
stone: require('../assets/images/patterns/stone-theme-tile.png'),
|
||||
lume: require('../assets/images/patterns/memo-theme-tile.png'),
|
||||
nature: require('../assets/images/patterns/nature-theme-tile.png'),
|
||||
stone: require('../assets/images/patterns/stone-theme-tile.png'),
|
||||
};
|
||||
|
||||
export const THEME_NAMES: Record<ThemeVariant, string> = {
|
||||
lume: 'Lume',
|
||||
nature: 'Nature',
|
||||
stone: 'Stone',
|
||||
lume: 'Lume',
|
||||
nature: 'Nature',
|
||||
stone: 'Stone',
|
||||
};
|
||||
|
||||
// Farbpalette
|
||||
export const lightColors = {
|
||||
primary: '#f8d62b',
|
||||
backgroundForPrimary: '#383838',
|
||||
primaryHover: '#2980b9',
|
||||
primaryPressed: '#1f6da3',
|
||||
secondary: '#D4B200',
|
||||
secondaryHover: '#27ae60',
|
||||
secondaryPressed: '#229954',
|
||||
backgroundPage: '#dddddd',
|
||||
backgroundPrimary: '#ffffff',
|
||||
backgroundSecondary: '#eeeeee', // Geändert zu einem helleren Grau
|
||||
backgroundTertiary: '#e8e8e8', // Neue Farbe für Hover-Zustand
|
||||
backgroundError: '#FFEBEE', // Helles Rot für Fehlerhintergrund
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
borderPrimary: '#bbbbbb',
|
||||
borderSecondary: '#282828',
|
||||
textOnPrimary: '#000000', // Schwarz für Text auf primärer Farbe
|
||||
error: '#e74c3c', // Rot für Gefahren-Buttons
|
||||
backgroundFree: '#95a5a6', // Grau
|
||||
backgroundPlus: '#f39c12', // Orange
|
||||
backgroundPro: '#f8d62b', // Gelb
|
||||
backgroundUltra: '#e74c3c', // Rot
|
||||
primary: '#f8d62b',
|
||||
backgroundForPrimary: '#383838',
|
||||
primaryHover: '#2980b9',
|
||||
primaryPressed: '#1f6da3',
|
||||
secondary: '#D4B200',
|
||||
secondaryHover: '#27ae60',
|
||||
secondaryPressed: '#229954',
|
||||
backgroundPage: '#dddddd',
|
||||
backgroundPrimary: '#ffffff',
|
||||
backgroundSecondary: '#eeeeee', // Geändert zu einem helleren Grau
|
||||
backgroundTertiary: '#e8e8e8', // Neue Farbe für Hover-Zustand
|
||||
backgroundError: '#FFEBEE', // Helles Rot für Fehlerhintergrund
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
borderPrimary: '#bbbbbb',
|
||||
borderSecondary: '#282828',
|
||||
textOnPrimary: '#000000', // Schwarz für Text auf primärer Farbe
|
||||
error: '#e74c3c', // Rot für Gefahren-Buttons
|
||||
backgroundFree: '#95a5a6', // Grau
|
||||
backgroundPlus: '#f39c12', // Orange
|
||||
backgroundPro: '#f8d62b', // Gelb
|
||||
backgroundUltra: '#e74c3c', // Rot
|
||||
};
|
||||
|
||||
export const darkColors = {
|
||||
primary: '#f8d62b',
|
||||
backgroundForPrimary: '#383838',
|
||||
primaryHover: '#f8d62b',
|
||||
primaryPressed: '#1f6da3',
|
||||
secondary: '#D4B200',
|
||||
secondaryHover: '#27ae60',
|
||||
secondaryPressed: '#229954',
|
||||
backgroundPage: '#121212',
|
||||
backgroundPrimary: '#1e1e1e',
|
||||
backgroundSecondary: '#2c2c2c',
|
||||
backgroundTertiary: '#383838', // Neue Farbe für Hover-Zustand
|
||||
backgroundError: '#260000', // Dunkles Rot für Fehlerhintergrund
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: '#cccccc',
|
||||
textTertiary: '#999999',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#424242',
|
||||
borderSecondary: '#282828',
|
||||
error: '#e74c3c', // Rot für Gefahren-Buttons
|
||||
backgroundFree: '#95a5a6', // Grau
|
||||
backgroundPlus: '#f39c12', // Orange
|
||||
backgroundPro: '#f8d62b', // Gelb
|
||||
backgroundUltra: '#e74c3c', // Rot
|
||||
primary: '#f8d62b',
|
||||
backgroundForPrimary: '#383838',
|
||||
primaryHover: '#f8d62b',
|
||||
primaryPressed: '#1f6da3',
|
||||
secondary: '#D4B200',
|
||||
secondaryHover: '#27ae60',
|
||||
secondaryPressed: '#229954',
|
||||
backgroundPage: '#121212',
|
||||
backgroundPrimary: '#1e1e1e',
|
||||
backgroundSecondary: '#2c2c2c',
|
||||
backgroundTertiary: '#383838', // Neue Farbe für Hover-Zustand
|
||||
backgroundError: '#260000', // Dunkles Rot für Fehlerhintergrund
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: '#cccccc',
|
||||
textTertiary: '#999999',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#424242',
|
||||
borderSecondary: '#282828',
|
||||
error: '#e74c3c', // Rot für Gefahren-Buttons
|
||||
backgroundFree: '#95a5a6', // Grau
|
||||
backgroundPlus: '#f39c12', // Orange
|
||||
backgroundPro: '#f8d62b', // Gelb
|
||||
backgroundUltra: '#e74c3c', // Rot
|
||||
};
|
||||
|
||||
// Nature theme colors
|
||||
export const natureLightColors = {
|
||||
primary: '#81C784',
|
||||
backgroundForPrimary: '#2E7D32',
|
||||
primaryHover: '#66BB6A',
|
||||
primaryPressed: '#4CAF50',
|
||||
secondary: '#A5D6A7',
|
||||
secondaryHover: '#81C784',
|
||||
secondaryPressed: '#66BB6A',
|
||||
backgroundPage: '#F1F8E9',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F9FBE7',
|
||||
backgroundTertiary: '#F0F4C3',
|
||||
backgroundError: '#FFEBEE',
|
||||
textPrimary: '#1B5E20',
|
||||
textSecondary: '#33691E',
|
||||
textTertiary: '#558B2F',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#C8E6C9',
|
||||
borderSecondary: '#A5D6A7',
|
||||
error: '#E57373',
|
||||
backgroundFree: '#AED581',
|
||||
backgroundPlus: '#9CCC65',
|
||||
backgroundPro: '#8BC34A',
|
||||
backgroundUltra: '#7CB342',
|
||||
primary: '#81C784',
|
||||
backgroundForPrimary: '#2E7D32',
|
||||
primaryHover: '#66BB6A',
|
||||
primaryPressed: '#4CAF50',
|
||||
secondary: '#A5D6A7',
|
||||
secondaryHover: '#81C784',
|
||||
secondaryPressed: '#66BB6A',
|
||||
backgroundPage: '#F1F8E9',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F9FBE7',
|
||||
backgroundTertiary: '#F0F4C3',
|
||||
backgroundError: '#FFEBEE',
|
||||
textPrimary: '#1B5E20',
|
||||
textSecondary: '#33691E',
|
||||
textTertiary: '#558B2F',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#C8E6C9',
|
||||
borderSecondary: '#A5D6A7',
|
||||
error: '#E57373',
|
||||
backgroundFree: '#AED581',
|
||||
backgroundPlus: '#9CCC65',
|
||||
backgroundPro: '#8BC34A',
|
||||
backgroundUltra: '#7CB342',
|
||||
};
|
||||
|
||||
export const natureDarkColors = {
|
||||
primary: '#81C784',
|
||||
backgroundForPrimary: '#2E7D32',
|
||||
primaryHover: '#66BB6A',
|
||||
primaryPressed: '#4CAF50',
|
||||
secondary: '#A5D6A7',
|
||||
secondaryHover: '#81C784',
|
||||
secondaryPressed: '#66BB6A',
|
||||
backgroundPage: '#1B1B1B',
|
||||
backgroundPrimary: '#1E1E1E',
|
||||
backgroundSecondary: '#2C2C2C',
|
||||
backgroundTertiary: '#333333',
|
||||
backgroundError: '#CF6679',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#C8E6C9',
|
||||
textTertiary: '#A5D6A7',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#2E7D32',
|
||||
borderSecondary: '#1B5E20',
|
||||
error: '#CF6679',
|
||||
backgroundFree: '#558B2F',
|
||||
backgroundPlus: '#7CB342',
|
||||
backgroundPro: '#8BC34A',
|
||||
backgroundUltra: '#9CCC65',
|
||||
primary: '#81C784',
|
||||
backgroundForPrimary: '#2E7D32',
|
||||
primaryHover: '#66BB6A',
|
||||
primaryPressed: '#4CAF50',
|
||||
secondary: '#A5D6A7',
|
||||
secondaryHover: '#81C784',
|
||||
secondaryPressed: '#66BB6A',
|
||||
backgroundPage: '#1B1B1B',
|
||||
backgroundPrimary: '#1E1E1E',
|
||||
backgroundSecondary: '#2C2C2C',
|
||||
backgroundTertiary: '#333333',
|
||||
backgroundError: '#CF6679',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#C8E6C9',
|
||||
textTertiary: '#A5D6A7',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#2E7D32',
|
||||
borderSecondary: '#1B5E20',
|
||||
error: '#CF6679',
|
||||
backgroundFree: '#558B2F',
|
||||
backgroundPlus: '#7CB342',
|
||||
backgroundPro: '#8BC34A',
|
||||
backgroundUltra: '#9CCC65',
|
||||
};
|
||||
|
||||
// Stone theme colors
|
||||
export const stoneLightColors = {
|
||||
primary: '#90A4AE',
|
||||
backgroundForPrimary: '#455A64',
|
||||
primaryHover: '#78909C',
|
||||
primaryPressed: '#607D8B',
|
||||
secondary: '#B0BEC5',
|
||||
secondaryHover: '#90A4AE',
|
||||
secondaryPressed: '#78909C',
|
||||
backgroundPage: '#ECEFF1',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F5F5F5',
|
||||
backgroundTertiary: '#EEEEEE',
|
||||
backgroundError: '#FFEBEE',
|
||||
textPrimary: '#263238',
|
||||
textSecondary: '#37474F',
|
||||
textTertiary: '#455A64',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#CFD8DC',
|
||||
borderSecondary: '#B0BEC5',
|
||||
error: '#EF5350',
|
||||
backgroundFree: '#90A4AE',
|
||||
backgroundPlus: '#78909C',
|
||||
backgroundPro: '#607D8B',
|
||||
backgroundUltra: '#546E7A',
|
||||
primary: '#90A4AE',
|
||||
backgroundForPrimary: '#455A64',
|
||||
primaryHover: '#78909C',
|
||||
primaryPressed: '#607D8B',
|
||||
secondary: '#B0BEC5',
|
||||
secondaryHover: '#90A4AE',
|
||||
secondaryPressed: '#78909C',
|
||||
backgroundPage: '#ECEFF1',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F5F5F5',
|
||||
backgroundTertiary: '#EEEEEE',
|
||||
backgroundError: '#FFEBEE',
|
||||
textPrimary: '#263238',
|
||||
textSecondary: '#37474F',
|
||||
textTertiary: '#455A64',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#CFD8DC',
|
||||
borderSecondary: '#B0BEC5',
|
||||
error: '#EF5350',
|
||||
backgroundFree: '#90A4AE',
|
||||
backgroundPlus: '#78909C',
|
||||
backgroundPro: '#607D8B',
|
||||
backgroundUltra: '#546E7A',
|
||||
};
|
||||
|
||||
export const stoneDarkColors = {
|
||||
primary: '#90A4AE',
|
||||
backgroundForPrimary: '#455A64',
|
||||
primaryHover: '#78909C',
|
||||
primaryPressed: '#607D8B',
|
||||
secondary: '#B0BEC5',
|
||||
secondaryHover: '#90A4AE',
|
||||
secondaryPressed: '#78909C',
|
||||
backgroundPage: '#121212',
|
||||
backgroundPrimary: '#1A1A1A',
|
||||
backgroundSecondary: '#242424',
|
||||
backgroundTertiary: '#2C2C2C',
|
||||
backgroundError: '#CF6679',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#B0BEC5',
|
||||
textTertiary: '#90A4AE',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#455A64',
|
||||
borderSecondary: '#37474F',
|
||||
error: '#CF6679',
|
||||
backgroundFree: '#546E7A',
|
||||
backgroundPlus: '#607D8B',
|
||||
backgroundPro: '#78909C',
|
||||
backgroundUltra: '#90A4AE',
|
||||
primary: '#90A4AE',
|
||||
backgroundForPrimary: '#455A64',
|
||||
primaryHover: '#78909C',
|
||||
primaryPressed: '#607D8B',
|
||||
secondary: '#B0BEC5',
|
||||
secondaryHover: '#90A4AE',
|
||||
secondaryPressed: '#78909C',
|
||||
backgroundPage: '#121212',
|
||||
backgroundPrimary: '#1A1A1A',
|
||||
backgroundSecondary: '#242424',
|
||||
backgroundTertiary: '#2C2C2C',
|
||||
backgroundError: '#CF6679',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#B0BEC5',
|
||||
textTertiary: '#90A4AE',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#455A64',
|
||||
borderSecondary: '#37474F',
|
||||
error: '#CF6679',
|
||||
backgroundFree: '#546E7A',
|
||||
backgroundPlus: '#607D8B',
|
||||
backgroundPro: '#78909C',
|
||||
backgroundUltra: '#90A4AE',
|
||||
};
|
||||
|
||||
// Schriftgrößen
|
||||
const fontSizes = {
|
||||
small: 14,
|
||||
body: 16,
|
||||
subtitle: 18,
|
||||
title: 20,
|
||||
h1: 28, // Neue Zeile für h1
|
||||
h2: 24, // Neue Zeile für h2
|
||||
small: 14,
|
||||
body: 16,
|
||||
subtitle: 18,
|
||||
title: 20,
|
||||
h1: 28, // Neue Zeile für h1
|
||||
h2: 24, // Neue Zeile für h2
|
||||
};
|
||||
|
||||
// Schriftstärken
|
||||
const fontWeights = {
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
bold: '700',
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
bold: '700',
|
||||
};
|
||||
|
||||
// Abstände
|
||||
const spacing = {
|
||||
none: 0,
|
||||
xxs: 2, // Neuer Wert für sehr kleine Abstände
|
||||
xsmall: 4,
|
||||
small: 8,
|
||||
medium: 12,
|
||||
large: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
xxxl: 64,
|
||||
horizontalPageMargin: 16,
|
||||
verticalPageMargin: 24,
|
||||
sectionSpacing: 40,
|
||||
elementSpacing: 20,
|
||||
inlineElementSpacing: 12,
|
||||
cardPadding: 16,
|
||||
headerHeight: 60,
|
||||
none: 0,
|
||||
xxs: 2, // Neuer Wert für sehr kleine Abstände
|
||||
xsmall: 4,
|
||||
small: 8,
|
||||
medium: 12,
|
||||
large: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
xxxl: 64,
|
||||
horizontalPageMargin: 16,
|
||||
verticalPageMargin: 24,
|
||||
sectionSpacing: 40,
|
||||
elementSpacing: 20,
|
||||
inlineElementSpacing: 12,
|
||||
cardPadding: 16,
|
||||
headerHeight: 60,
|
||||
};
|
||||
|
||||
// Rundungen
|
||||
const borderRadius = {
|
||||
small: 4,
|
||||
medium: 12,
|
||||
large: 16,
|
||||
round: 9999,
|
||||
small: 4,
|
||||
medium: 12,
|
||||
large: 16,
|
||||
round: 9999,
|
||||
};
|
||||
|
||||
// Schatten aktualisieren
|
||||
const shadows = {
|
||||
small: '0px 2px 4px 0px rgba(0, 0, 0, 0.1)',
|
||||
medium: '0px 4px 8px 0px rgba(0, 0, 0, 0.15)',
|
||||
large: '0px 6px 12px 0px rgba(0, 0, 0, 0.2)',
|
||||
small: '0px 2px 4px 0px rgba(0, 0, 0, 0.1)',
|
||||
medium: '0px 4px 8px 0px rgba(0, 0, 0, 0.15)',
|
||||
large: '0px 6px 12px 0px rgba(0, 0, 0, 0.2)',
|
||||
};
|
||||
|
||||
// Z-Index-Werte
|
||||
const zIndex = {
|
||||
base: 1,
|
||||
dropdown: 1000,
|
||||
modal: 2000,
|
||||
tooltip: 3000,
|
||||
base: 1,
|
||||
dropdown: 1000,
|
||||
modal: 2000,
|
||||
tooltip: 3000,
|
||||
};
|
||||
|
||||
// Neue TagColors mit englischen Namen
|
||||
export const tagColors = {
|
||||
blue: '#3498db',
|
||||
green: '#2ecc71',
|
||||
red: '#e74c3c',
|
||||
orange: '#f39c12',
|
||||
purple: '#9b59b6',
|
||||
teal: '#1abc9c',
|
||||
pink: '#e84393',
|
||||
gray: '#95a5a6',
|
||||
blue: '#3498db',
|
||||
green: '#2ecc71',
|
||||
red: '#e74c3c',
|
||||
orange: '#f39c12',
|
||||
purple: '#9b59b6',
|
||||
teal: '#1abc9c',
|
||||
pink: '#e84393',
|
||||
gray: '#95a5a6',
|
||||
};
|
||||
|
||||
// Updated getTheme function to support multiple themes
|
||||
export const getTheme = (colorScheme: ColorSchemeName, themeVariant: ThemeVariant = 'lume') => {
|
||||
let colors;
|
||||
switch (themeVariant) {
|
||||
case 'nature':
|
||||
colors = colorScheme === 'dark' ? natureDarkColors : natureLightColors;
|
||||
break;
|
||||
case 'stone':
|
||||
colors = colorScheme === 'dark' ? stoneDarkColors : stoneLightColors;
|
||||
break;
|
||||
default:
|
||||
colors = colorScheme === 'dark' ? darkColors : lightColors;
|
||||
}
|
||||
let colors;
|
||||
switch (themeVariant) {
|
||||
case 'nature':
|
||||
colors = colorScheme === 'dark' ? natureDarkColors : natureLightColors;
|
||||
break;
|
||||
case 'stone':
|
||||
colors = colorScheme === 'dark' ? stoneDarkColors : stoneLightColors;
|
||||
break;
|
||||
default:
|
||||
colors = colorScheme === 'dark' ? darkColors : lightColors;
|
||||
}
|
||||
|
||||
return {
|
||||
colors,
|
||||
tagColors,
|
||||
fontSizes,
|
||||
fontWeights,
|
||||
spacing,
|
||||
borderRadius,
|
||||
shadows,
|
||||
zIndex,
|
||||
};
|
||||
return {
|
||||
colors,
|
||||
tagColors,
|
||||
fontSizes,
|
||||
fontWeights,
|
||||
spacing,
|
||||
borderRadius,
|
||||
shadows,
|
||||
zIndex,
|
||||
};
|
||||
};
|
||||
|
||||
// Typdefinition für das Theme
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 13.4.2",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
"cli": {
|
||||
"version": ">= 13.4.2",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ const { getDefaultConfig } = require('@expo/metro-config');
|
|||
const defaultConfig = getDefaultConfig(__dirname);
|
||||
defaultConfig.resolver.sourceExts.push('cjs');
|
||||
|
||||
module.exports = defaultConfig;
|
||||
module.exports = defaultConfig;
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
{
|
||||
"name": "@presi/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@presi/shared": "workspace:*",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"expo": "~52.0.11",
|
||||
"expo-blur": "~14.0.1",
|
||||
"expo-constants": "~17.0.3",
|
||||
"expo-font": "~13.0.1",
|
||||
"expo-haptics": "~14.0.0",
|
||||
"expo-image-picker": "~16.0.3",
|
||||
"expo-linking": "~7.0.3",
|
||||
"expo-router": "~4.0.9",
|
||||
"expo-screen-orientation": "~8.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-splash-screen": "~0.29.13",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-symbols": "~0.2.0",
|
||||
"expo-system-ui": "~4.0.4",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.3",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.2",
|
||||
"zeego": "^2.0.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@presi/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@presi/shared": "workspace:*",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"expo": "~52.0.11",
|
||||
"expo-blur": "~14.0.1",
|
||||
"expo-constants": "~17.0.3",
|
||||
"expo-font": "~13.0.1",
|
||||
"expo-haptics": "~14.0.0",
|
||||
"expo-image-picker": "~16.0.3",
|
||||
"expo-linking": "~7.0.3",
|
||||
"expo-router": "~4.0.9",
|
||||
"expo-screen-orientation": "~8.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-splash-screen": "~0.29.13",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-symbols": "~0.2.0",
|
||||
"expo-system-ui": "~4.0.4",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.3",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.2",
|
||||
"zeego": "^2.0.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,49 @@
|
|||
import {
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signOut,
|
||||
sendPasswordResetEmail,
|
||||
onAuthStateChanged,
|
||||
User
|
||||
import {
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signOut,
|
||||
sendPasswordResetEmail,
|
||||
onAuthStateChanged,
|
||||
User,
|
||||
} from 'firebase/auth';
|
||||
import { auth } from '../firebaseConfig';
|
||||
import { doc, setDoc } from 'firebase/firestore';
|
||||
import { db } from '../firebaseConfig';
|
||||
|
||||
export const loginUser = async (email: string, password: string): Promise<User> => {
|
||||
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||
return userCredential.user;
|
||||
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||
return userCredential.user;
|
||||
};
|
||||
|
||||
export const registerUser = async (email: string, password: string): Promise<User> => {
|
||||
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||
|
||||
// Create user document in Firestore
|
||||
await setDoc(doc(db, 'users', userCredential.user.uid), {
|
||||
email: userCredential.user.email,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
return userCredential.user;
|
||||
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||
|
||||
// Create user document in Firestore
|
||||
await setDoc(doc(db, 'users', userCredential.user.uid), {
|
||||
email: userCredential.user.email,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return userCredential.user;
|
||||
};
|
||||
|
||||
export const logoutUser = async (): Promise<void> => {
|
||||
await signOut(auth);
|
||||
await signOut(auth);
|
||||
};
|
||||
|
||||
export const resetPassword = async (email: string): Promise<void> => {
|
||||
await sendPasswordResetEmail(auth, email);
|
||||
await sendPasswordResetEmail(auth, email);
|
||||
};
|
||||
|
||||
export const getCurrentUser = (): User | null => {
|
||||
return auth.currentUser;
|
||||
return auth.currentUser;
|
||||
};
|
||||
|
||||
export const onAuthStateChange = (callback: (user: User | null) => void): (() => void) => {
|
||||
return onAuthStateChanged(auth, callback);
|
||||
return onAuthStateChanged(auth, callback);
|
||||
};
|
||||
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return auth.currentUser !== null;
|
||||
};
|
||||
return auth.currentUser !== null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,295 +1,292 @@
|
|||
import {
|
||||
collection,
|
||||
query,
|
||||
where,
|
||||
getDocs,
|
||||
addDoc,
|
||||
updateDoc,
|
||||
deleteDoc,
|
||||
doc,
|
||||
getDoc,
|
||||
orderBy,
|
||||
limit,
|
||||
writeBatch
|
||||
import {
|
||||
collection,
|
||||
query,
|
||||
where,
|
||||
getDocs,
|
||||
addDoc,
|
||||
updateDoc,
|
||||
deleteDoc,
|
||||
doc,
|
||||
getDoc,
|
||||
orderBy,
|
||||
limit,
|
||||
writeBatch,
|
||||
} from 'firebase/firestore';
|
||||
import { db, auth } from '../firebaseConfig';
|
||||
import { Deck, Slide } from '../types/models';
|
||||
|
||||
// Decks
|
||||
export const getUserDecks = async (userId: string): Promise<Deck[]> => {
|
||||
console.log('[Firestore] Getting all decks for user:', userId);
|
||||
|
||||
try {
|
||||
const decksRef = collection(db, 'decks');
|
||||
const q = query(
|
||||
decksRef,
|
||||
where('userId', '==', userId),
|
||||
orderBy('createdAt', 'desc')
|
||||
);
|
||||
console.log('[Firestore] Getting all decks for user:', userId);
|
||||
|
||||
const querySnapshot = await getDocs(q);
|
||||
const decks = querySnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
createdAt: data.createdAt?.toDate() || new Date(),
|
||||
updatedAt: data.updatedAt?.toDate() || new Date(),
|
||||
} as Deck;
|
||||
});
|
||||
|
||||
console.log('[Firestore] Retrieved decks:', decks.length);
|
||||
return decks;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting decks:', error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const decksRef = collection(db, 'decks');
|
||||
const q = query(decksRef, where('userId', '==', userId), orderBy('createdAt', 'desc'));
|
||||
|
||||
const querySnapshot = await getDocs(q);
|
||||
const decks = querySnapshot.docs.map((doc) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
createdAt: data.createdAt?.toDate() || new Date(),
|
||||
updatedAt: data.updatedAt?.toDate() || new Date(),
|
||||
} as Deck;
|
||||
});
|
||||
|
||||
console.log('[Firestore] Retrieved decks:', decks.length);
|
||||
return decks;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting decks:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createDeck = async (deckData: Partial<Deck>): Promise<Deck> => {
|
||||
console.log('[Firestore] Creating new deck:', deckData);
|
||||
|
||||
try {
|
||||
if (!auth.currentUser) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
console.log('[Firestore] Creating new deck:', deckData);
|
||||
|
||||
const now = new Date();
|
||||
const newDeck = {
|
||||
...deckData,
|
||||
userId: auth.currentUser.uid,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sharing: {
|
||||
isPublic: false,
|
||||
collaborators: {},
|
||||
},
|
||||
};
|
||||
try {
|
||||
if (!auth.currentUser) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
|
||||
const docRef = await addDoc(collection(db, 'decks'), newDeck);
|
||||
|
||||
return {
|
||||
id: docRef.id,
|
||||
...newDeck,
|
||||
} as Deck;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error creating deck:', error);
|
||||
throw error;
|
||||
}
|
||||
const now = new Date();
|
||||
const newDeck = {
|
||||
...deckData,
|
||||
userId: auth.currentUser.uid,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sharing: {
|
||||
isPublic: false,
|
||||
collaborators: {},
|
||||
},
|
||||
};
|
||||
|
||||
const docRef = await addDoc(collection(db, 'decks'), newDeck);
|
||||
|
||||
return {
|
||||
id: docRef.id,
|
||||
...newDeck,
|
||||
} as Deck;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error creating deck:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDeck = async (deckId: string): Promise<Deck> => {
|
||||
console.log('[Firestore] Getting deck:', deckId);
|
||||
|
||||
try {
|
||||
const deckRef = doc(db, 'decks', deckId);
|
||||
const deckDoc = await getDoc(deckRef);
|
||||
|
||||
if (!deckDoc.exists()) {
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
console.log('[Firestore] Getting deck:', deckId);
|
||||
|
||||
const data = deckDoc.data();
|
||||
return {
|
||||
id: deckDoc.id,
|
||||
...data,
|
||||
createdAt: data.createdAt?.toDate() || new Date(),
|
||||
updatedAt: data.updatedAt?.toDate() || new Date(),
|
||||
} as Deck;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting deck:', error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const deckRef = doc(db, 'decks', deckId);
|
||||
const deckDoc = await getDoc(deckRef);
|
||||
|
||||
if (!deckDoc.exists()) {
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
const data = deckDoc.data();
|
||||
return {
|
||||
id: deckDoc.id,
|
||||
...data,
|
||||
createdAt: data.createdAt?.toDate() || new Date(),
|
||||
updatedAt: data.updatedAt?.toDate() || new Date(),
|
||||
} as Deck;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting deck:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDeck = async (deckId: string): Promise<void> => {
|
||||
try {
|
||||
console.log('[Firestore] Deleting deck:', deckId);
|
||||
const deckRef = doc(db, 'decks', deckId);
|
||||
await deleteDoc(deckRef);
|
||||
console.log('[Firestore] Deck deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error deleting deck:', error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
console.log('[Firestore] Deleting deck:', deckId);
|
||||
const deckRef = doc(db, 'decks', deckId);
|
||||
await deleteDoc(deckRef);
|
||||
console.log('[Firestore] Deck deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error deleting deck:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Slides
|
||||
export const getDeckSlides = async (deckId: string): Promise<Slide[]> => {
|
||||
console.log('[Firestore] Getting slides for deck:', deckId);
|
||||
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', deckId, 'slides');
|
||||
const q = query(slidesRef, orderBy('order', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
const slides = querySnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
createdAt: doc.data().createdAt?.toDate() || new Date(),
|
||||
updatedAt: doc.data().updatedAt?.toDate() || new Date(),
|
||||
})) as Slide[];
|
||||
console.log('[Firestore] Getting slides for deck:', deckId);
|
||||
|
||||
console.log('[Firestore] Retrieved slides:', slides.length);
|
||||
return slides;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting slides:', error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', deckId, 'slides');
|
||||
const q = query(slidesRef, orderBy('order', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
const slides = querySnapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
createdAt: doc.data().createdAt?.toDate() || new Date(),
|
||||
updatedAt: doc.data().updatedAt?.toDate() || new Date(),
|
||||
})) as Slide[];
|
||||
|
||||
console.log('[Firestore] Retrieved slides:', slides.length);
|
||||
return slides;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting slides:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSlide = async (slideData: Partial<Slide>): Promise<Slide> => {
|
||||
console.log('[Firestore] Creating new slide:', slideData);
|
||||
|
||||
if (!slideData.deckId) {
|
||||
throw new Error('deckId is required to create a slide');
|
||||
}
|
||||
console.log('[Firestore] Creating new slide:', slideData);
|
||||
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', slideData.deckId, 'slides');
|
||||
const now = new Date();
|
||||
|
||||
// Get the current highest order
|
||||
const q = query(slidesRef, orderBy('order', 'desc'), limit(1));
|
||||
const querySnapshot = await getDocs(q);
|
||||
const highestOrder = querySnapshot.empty ? 0 : querySnapshot.docs[0].data().order;
|
||||
if (!slideData.deckId) {
|
||||
throw new Error('deckId is required to create a slide');
|
||||
}
|
||||
|
||||
const newSlide = {
|
||||
...slideData,
|
||||
order: highestOrder + 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', slideData.deckId, 'slides');
|
||||
const now = new Date();
|
||||
|
||||
const docRef = await addDoc(slidesRef, newSlide);
|
||||
return {
|
||||
id: docRef.id,
|
||||
...newSlide,
|
||||
} as Slide;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error creating slide:', error);
|
||||
throw error;
|
||||
}
|
||||
// Get the current highest order
|
||||
const q = query(slidesRef, orderBy('order', 'desc'), limit(1));
|
||||
const querySnapshot = await getDocs(q);
|
||||
const highestOrder = querySnapshot.empty ? 0 : querySnapshot.docs[0].data().order;
|
||||
|
||||
const newSlide = {
|
||||
...slideData,
|
||||
order: highestOrder + 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const docRef = await addDoc(slidesRef, newSlide);
|
||||
return {
|
||||
id: docRef.id,
|
||||
...newSlide,
|
||||
} as Slide;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error creating slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSlide = async (slideId: string, slideData: Partial<Slide>): Promise<void> => {
|
||||
console.log('[Firestore] Updating slide:', slideId, slideData);
|
||||
|
||||
if (!slideData.deckId) {
|
||||
throw new Error('deckId is required to update a slide');
|
||||
}
|
||||
console.log('[Firestore] Updating slide:', slideId, slideData);
|
||||
|
||||
try {
|
||||
const slideRef = doc(db, 'decks', slideData.deckId, 'slides', slideId);
|
||||
await updateDoc(slideRef, {
|
||||
...slideData,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error updating slide:', error);
|
||||
throw error;
|
||||
}
|
||||
if (!slideData.deckId) {
|
||||
throw new Error('deckId is required to update a slide');
|
||||
}
|
||||
|
||||
try {
|
||||
const slideRef = doc(db, 'decks', slideData.deckId, 'slides', slideId);
|
||||
await updateDoc(slideRef, {
|
||||
...slideData,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error updating slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSlide = async (slideId: string, deckId: string): Promise<void> => {
|
||||
console.log('[Firestore] Deleting slide:', slideId);
|
||||
|
||||
try {
|
||||
const slideRef = doc(db, 'decks', deckId, 'slides', slideId);
|
||||
await deleteDoc(slideRef);
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error deleting slide:', error);
|
||||
throw error;
|
||||
}
|
||||
console.log('[Firestore] Deleting slide:', slideId);
|
||||
|
||||
try {
|
||||
const slideRef = doc(db, 'decks', deckId, 'slides', slideId);
|
||||
await deleteDoc(slideRef);
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error deleting slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const reorderSlide = async (slideId: string, newOrder: number, deckId: string): Promise<void> => {
|
||||
console.log('[Firestore] Reordering slide:', slideId, 'to order:', newOrder);
|
||||
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', deckId, 'slides');
|
||||
const batch = writeBatch(db);
|
||||
const now = new Date();
|
||||
export const reorderSlide = async (
|
||||
slideId: string,
|
||||
newOrder: number,
|
||||
deckId: string
|
||||
): Promise<void> => {
|
||||
console.log('[Firestore] Reordering slide:', slideId, 'to order:', newOrder);
|
||||
|
||||
// Get all slides in the deck
|
||||
const q = query(slidesRef, orderBy('order', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
const slides = querySnapshot.docs;
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', deckId, 'slides');
|
||||
const batch = writeBatch(db);
|
||||
const now = new Date();
|
||||
|
||||
// Find the current slide and its order
|
||||
const currentSlide = slides.find(doc => doc.id === slideId);
|
||||
if (!currentSlide) {
|
||||
throw new Error('Slide not found');
|
||||
}
|
||||
const currentOrder = currentSlide.data().order;
|
||||
// Get all slides in the deck
|
||||
const q = query(slidesRef, orderBy('order', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
const slides = querySnapshot.docs;
|
||||
|
||||
// Update orders
|
||||
slides.forEach(doc => {
|
||||
const slideOrder = doc.data().order;
|
||||
if (doc.id === slideId) {
|
||||
// Update the target slide
|
||||
batch.update(doc.ref, {
|
||||
order: newOrder,
|
||||
updatedAt: now
|
||||
});
|
||||
} else if (newOrder > currentOrder && slideOrder > currentOrder && slideOrder <= newOrder) {
|
||||
// Move slides up
|
||||
batch.update(doc.ref, {
|
||||
order: slideOrder - 1,
|
||||
updatedAt: now
|
||||
});
|
||||
} else if (newOrder < currentOrder && slideOrder >= newOrder && slideOrder < currentOrder) {
|
||||
// Move slides down
|
||||
batch.update(doc.ref, {
|
||||
order: slideOrder + 1,
|
||||
updatedAt: now
|
||||
});
|
||||
}
|
||||
});
|
||||
// Find the current slide and its order
|
||||
const currentSlide = slides.find((doc) => doc.id === slideId);
|
||||
if (!currentSlide) {
|
||||
throw new Error('Slide not found');
|
||||
}
|
||||
const currentOrder = currentSlide.data().order;
|
||||
|
||||
await batch.commit();
|
||||
console.log('[Firestore] Reorder operation completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error reordering slide:', error);
|
||||
throw error;
|
||||
}
|
||||
// Update orders
|
||||
slides.forEach((doc) => {
|
||||
const slideOrder = doc.data().order;
|
||||
if (doc.id === slideId) {
|
||||
// Update the target slide
|
||||
batch.update(doc.ref, {
|
||||
order: newOrder,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (newOrder > currentOrder && slideOrder > currentOrder && slideOrder <= newOrder) {
|
||||
// Move slides up
|
||||
batch.update(doc.ref, {
|
||||
order: slideOrder - 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (newOrder < currentOrder && slideOrder >= newOrder && slideOrder < currentOrder) {
|
||||
// Move slides down
|
||||
batch.update(doc.ref, {
|
||||
order: slideOrder + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
console.log('[Firestore] Reorder operation completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error reordering slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const migrateDecksToNewSchema = async (userId: string) => {
|
||||
console.log('[Firestore] Migrating decks to new schema for user:', userId);
|
||||
|
||||
try {
|
||||
const decksRef = collection(db, 'decks');
|
||||
const q = query(
|
||||
decksRef,
|
||||
where('userId', '==', userId)
|
||||
);
|
||||
console.log('[Firestore] Migrating decks to new schema for user:', userId);
|
||||
|
||||
const querySnapshot = await getDocs(q);
|
||||
const batch = writeBatch(db);
|
||||
let updateCount = 0;
|
||||
try {
|
||||
const decksRef = collection(db, 'decks');
|
||||
const q = query(decksRef, where('userId', '==', userId));
|
||||
|
||||
querySnapshot.docs.forEach(docSnapshot => {
|
||||
const deckData = docSnapshot.data();
|
||||
if (!deckData.sharing) {
|
||||
batch.update(docSnapshot.ref, {
|
||||
sharing: {
|
||||
isPublic: false,
|
||||
collaborators: {},
|
||||
}
|
||||
});
|
||||
updateCount++;
|
||||
}
|
||||
});
|
||||
const querySnapshot = await getDocs(q);
|
||||
const batch = writeBatch(db);
|
||||
let updateCount = 0;
|
||||
|
||||
if (updateCount > 0) {
|
||||
await batch.commit();
|
||||
console.log(`[Firestore] Successfully migrated ${updateCount} decks`);
|
||||
} else {
|
||||
console.log('[Firestore] No decks needed migration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error migrating decks:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
querySnapshot.docs.forEach((docSnapshot) => {
|
||||
const deckData = docSnapshot.data();
|
||||
if (!deckData.sharing) {
|
||||
batch.update(docSnapshot.ref, {
|
||||
sharing: {
|
||||
isPublic: false,
|
||||
collaborators: {},
|
||||
},
|
||||
});
|
||||
updateCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (updateCount > 0) {
|
||||
await batch.commit();
|
||||
console.log(`[Firestore] Successfully migrated ${updateCount} decks`);
|
||||
} else {
|
||||
console.log('[Firestore] No decks needed migration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error migrating decks:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@ import { storage } from '../firebaseConfig';
|
|||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
|
||||
export const uploadImage = async (file: Blob, path: string): Promise<string> => {
|
||||
try {
|
||||
const storageRef = ref(storage, path);
|
||||
const metadata = {
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: 'public,max-age=3600'
|
||||
};
|
||||
const snapshot = await uploadBytes(storageRef, file, metadata);
|
||||
const downloadURL = await getDownloadURL(snapshot.ref);
|
||||
return downloadURL;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error uploading image:', error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const storageRef = ref(storage, path);
|
||||
const metadata = {
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: 'public,max-age=3600',
|
||||
};
|
||||
const snapshot = await uploadBytes(storageRef, file, metadata);
|
||||
const downloadURL = await getDownloadURL(snapshot.ref);
|
||||
return downloadURL;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error uploading image:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadImages = async (files: Blob[], basePath: string): Promise<string[]> => {
|
||||
try {
|
||||
const uploadPromises = files.map((file, index) => {
|
||||
const path = `${basePath}/${index}_${Date.now()}.jpg`;
|
||||
return uploadImage(file, path);
|
||||
});
|
||||
return await Promise.all(uploadPromises);
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error uploading images:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const uploadPromises = files.map((file, index) => {
|
||||
const path = `${basePath}/${index}_${Date.now()}.jpg`;
|
||||
return uploadImage(file, path);
|
||||
});
|
||||
return await Promise.all(uploadPromises);
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error uploading images:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Portable Theme Module
|
||||
|
||||
Dieses Modul enthält ein komplettes Theme-System für React Native Apps mit:
|
||||
|
||||
- Hell/Dunkel Modus (inkl. System-Einstellung)
|
||||
- Kontrast-Einstellungen (5 Stufen)
|
||||
- Theme Provider & Hooks
|
||||
|
|
@ -10,6 +11,7 @@ Dieses Modul enthält ein komplettes Theme-System für React Native Apps mit:
|
|||
|
||||
1. Kopiere den gesamten `theme` Ordner in dein Projekt
|
||||
2. Installiere die benötigten Dependencies:
|
||||
|
||||
```bash
|
||||
npm install @react-native-async-storage/async-storage
|
||||
```
|
||||
|
|
@ -17,43 +19,44 @@ npm install @react-native-async-storage/async-storage
|
|||
## Verwendung
|
||||
|
||||
1. Wrapp deine App mit dem ThemeProvider:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from './theme';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<YourApp />
|
||||
</ThemeProvider>
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<YourApp />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. Nutze den useTheme Hook in deinen Komponenten:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from './theme';
|
||||
|
||||
export function MyComponent() {
|
||||
const { theme, isDark } = useTheme();
|
||||
return (
|
||||
<View style={{ backgroundColor: theme.colors.background }}>
|
||||
<Text style={{ color: theme.colors.text }}>
|
||||
Hello World
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
const { theme, isDark } = useTheme();
|
||||
return (
|
||||
<View style={{ backgroundColor: theme.colors.background }}>
|
||||
<Text style={{ color: theme.colors.text }}>Hello World</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
3. Füge die ThemeSettings Komponente in deine Settings-Seite ein:
|
||||
|
||||
```tsx
|
||||
import { ThemeSettings } from './theme';
|
||||
|
||||
export function SettingsScreen() {
|
||||
return (
|
||||
<View>
|
||||
<ThemeSettings />
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<ThemeSettings />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -7,164 +7,184 @@ export type ColorMode = 'system' | 'light' | 'dark';
|
|||
export type ContrastLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
COLOR_MODE: '@theme/colorMode',
|
||||
CONTRAST_LEVEL: '@theme/contrastLevel',
|
||||
COLOR_MODE: '@theme/colorMode',
|
||||
CONTRAST_LEVEL: '@theme/contrastLevel',
|
||||
};
|
||||
|
||||
type ThemeContextType = {
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
contrastLevel: ContrastLevel;
|
||||
setContrastLevel: (level: ContrastLevel) => void;
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
contrastLevel: ContrastLevel;
|
||||
setContrastLevel: (level: ContrastLevel) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: getTheme('light'),
|
||||
isDark: false,
|
||||
colorMode: 'system',
|
||||
setColorMode: () => {},
|
||||
contrastLevel: 3,
|
||||
setContrastLevel: () => {},
|
||||
theme: getTheme('light'),
|
||||
isDark: false,
|
||||
colorMode: 'system',
|
||||
setColorMode: () => {},
|
||||
contrastLevel: 3,
|
||||
setContrastLevel: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von Hex zu RGB
|
||||
const hexToRgb = (hex: string) => {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.substr(0, 2), 16),
|
||||
g: parseInt(h.substr(2, 2), 16),
|
||||
b: parseInt(h.substr(4, 2), 16),
|
||||
};
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.substr(0, 2), 16),
|
||||
g: parseInt(h.substr(2, 2), 16),
|
||||
b: parseInt(h.substr(4, 2), 16),
|
||||
};
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von RGB zu Hex mit Alpha
|
||||
const rgbaToHex = (r: number, g: number, b: number, a: number = 1) => {
|
||||
const alpha = Math.round(a * 255);
|
||||
return '#' + [r, g, b, alpha].map(x => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
const alpha = Math.round(a * 255);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b, alpha]
|
||||
.map((x) => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
// Funktion zum Anpassen des Kontrasts
|
||||
const adjustContrast = (color: string, level: ContrastLevel, type: 'text' | 'primary' | 'background', isDark: boolean): string => {
|
||||
if (level === 3) return color;
|
||||
const adjustContrast = (
|
||||
color: string,
|
||||
level: ContrastLevel,
|
||||
type: 'text' | 'primary' | 'background',
|
||||
isDark: boolean
|
||||
): string => {
|
||||
if (level === 3) return color;
|
||||
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
|
||||
if (level < 3) {
|
||||
// Niedrigerer Kontrast: Nur Text-Opacity wird reduziert
|
||||
if (type === 'text') {
|
||||
const opacity = 0.5 + (level - 1) * 0.25; // 0.5 für Level 1, 0.75 für Level 2
|
||||
return rgbaToHex(r, g, b, opacity);
|
||||
}
|
||||
return color;
|
||||
} else {
|
||||
// Höherer Kontrast: Nur Hintergründe werden angepasst
|
||||
if (type === 'background') {
|
||||
const factor = (level - 3) * 0.45; // 0.45 für Level 4, 0.9 für Level 5
|
||||
if (isDark) {
|
||||
// Im Dark Mode: Hintergründe werden schwärzer
|
||||
return rgbaToHex(
|
||||
Math.round(r * (1 - factor)),
|
||||
Math.round(g * (1 - factor)),
|
||||
Math.round(b * (1 - factor))
|
||||
);
|
||||
} else {
|
||||
// Im Light Mode: Hintergründe werden weißer
|
||||
return rgbaToHex(
|
||||
Math.round(r + (255 - r) * factor),
|
||||
Math.round(g + (255 - g) * factor),
|
||||
Math.round(b + (255 - b) * factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
|
||||
if (level < 3) {
|
||||
// Niedrigerer Kontrast: Nur Text-Opacity wird reduziert
|
||||
if (type === 'text') {
|
||||
const opacity = 0.5 + (level - 1) * 0.25; // 0.5 für Level 1, 0.75 für Level 2
|
||||
return rgbaToHex(r, g, b, opacity);
|
||||
}
|
||||
return color;
|
||||
} else {
|
||||
// Höherer Kontrast: Nur Hintergründe werden angepasst
|
||||
if (type === 'background') {
|
||||
const factor = (level - 3) * 0.45; // 0.45 für Level 4, 0.9 für Level 5
|
||||
if (isDark) {
|
||||
// Im Dark Mode: Hintergründe werden schwärzer
|
||||
return rgbaToHex(
|
||||
Math.round(r * (1 - factor)),
|
||||
Math.round(g * (1 - factor)),
|
||||
Math.round(b * (1 - factor))
|
||||
);
|
||||
} else {
|
||||
// Im Light Mode: Hintergründe werden weißer
|
||||
return rgbaToHex(
|
||||
Math.round(r + (255 - r) * factor),
|
||||
Math.round(g + (255 - g) * factor),
|
||||
Math.round(b + (255 - b) * factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Anpassen des gesamten Themes basierend auf dem Kontrast-Level
|
||||
const adjustThemeContrast = (theme: Theme, level: ContrastLevel, isDark: boolean): Theme => {
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
textPrimary: adjustContrast(theme.colors.textPrimary, level, 'text', isDark),
|
||||
textSecondary: adjustContrast(theme.colors.textSecondary, level, 'text', isDark),
|
||||
backgroundPage: adjustContrast(theme.colors.backgroundPage, level, 'background', isDark),
|
||||
backgroundPrimary: adjustContrast(theme.colors.backgroundPrimary, level, 'background', isDark),
|
||||
backgroundSecondary: adjustContrast(theme.colors.backgroundSecondary, level, 'background', isDark),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
textPrimary: adjustContrast(theme.colors.textPrimary, level, 'text', isDark),
|
||||
textSecondary: adjustContrast(theme.colors.textSecondary, level, 'text', isDark),
|
||||
backgroundPage: adjustContrast(theme.colors.backgroundPage, level, 'background', isDark),
|
||||
backgroundPrimary: adjustContrast(
|
||||
theme.colors.backgroundPrimary,
|
||||
level,
|
||||
'background',
|
||||
isDark
|
||||
),
|
||||
backgroundSecondary: adjustContrast(
|
||||
theme.colors.backgroundSecondary,
|
||||
level,
|
||||
'background',
|
||||
isDark
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('system');
|
||||
const [contrastLevel, setContrastLevel] = useState<ContrastLevel>(3);
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('system');
|
||||
const [contrastLevel, setContrastLevel] = useState<ContrastLevel>(3);
|
||||
|
||||
// Lade gespeicherte Einstellungen
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const savedColorMode = await AsyncStorage.getItem(STORAGE_KEYS.COLOR_MODE);
|
||||
if (savedColorMode) {
|
||||
setColorMode(savedColorMode as ColorMode);
|
||||
}
|
||||
// Lade gespeicherte Einstellungen
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const savedColorMode = await AsyncStorage.getItem(STORAGE_KEYS.COLOR_MODE);
|
||||
if (savedColorMode) {
|
||||
setColorMode(savedColorMode as ColorMode);
|
||||
}
|
||||
|
||||
const savedContrastLevel = await AsyncStorage.getItem(STORAGE_KEYS.CONTRAST_LEVEL);
|
||||
if (savedContrastLevel) {
|
||||
setContrastLevel(parseInt(savedContrastLevel) as ContrastLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme settings:', error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
const savedContrastLevel = await AsyncStorage.getItem(STORAGE_KEYS.CONTRAST_LEVEL);
|
||||
if (savedContrastLevel) {
|
||||
setContrastLevel(parseInt(savedContrastLevel) as ContrastLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme settings:', error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Speichere Einstellungen bei Änderungen
|
||||
const handleColorModeChange = async (mode: ColorMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode);
|
||||
setColorMode(mode);
|
||||
} catch (error) {
|
||||
console.error('Error saving color mode:', error);
|
||||
}
|
||||
};
|
||||
// Speichere Einstellungen bei Änderungen
|
||||
const handleColorModeChange = async (mode: ColorMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode);
|
||||
setColorMode(mode);
|
||||
} catch (error) {
|
||||
console.error('Error saving color mode:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContrastLevelChange = async (level: ContrastLevel) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.CONTRAST_LEVEL, level.toString());
|
||||
setContrastLevel(level);
|
||||
} catch (error) {
|
||||
console.error('Error saving contrast level:', error);
|
||||
}
|
||||
};
|
||||
const handleContrastLevelChange = async (level: ContrastLevel) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.CONTRAST_LEVEL, level.toString());
|
||||
setContrastLevel(level);
|
||||
} catch (error) {
|
||||
console.error('Error saving contrast level:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Bestimme den aktiven Modus
|
||||
const isDark = colorMode === 'system'
|
||||
? systemColorScheme === 'dark'
|
||||
: colorMode === 'dark';
|
||||
// Bestimme den aktiven Modus
|
||||
const isDark = colorMode === 'system' ? systemColorScheme === 'dark' : colorMode === 'dark';
|
||||
|
||||
// Hole das Basis-Theme und passe den Kontrast an
|
||||
const baseTheme = getTheme(isDark ? 'dark' : 'light');
|
||||
const theme = adjustThemeContrast(baseTheme, contrastLevel, isDark);
|
||||
// Hole das Basis-Theme und passe den Kontrast an
|
||||
const baseTheme = getTheme(isDark ? 'dark' : 'light');
|
||||
const theme = adjustThemeContrast(baseTheme, contrastLevel, isDark);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{
|
||||
theme,
|
||||
isDark,
|
||||
colorMode,
|
||||
setColorMode: handleColorModeChange,
|
||||
contrastLevel,
|
||||
setContrastLevel: handleContrastLevelChange,
|
||||
}}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
isDark,
|
||||
colorMode,
|
||||
setColorMode: handleColorModeChange,
|
||||
contrastLevel,
|
||||
setContrastLevel: handleContrastLevelChange,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,133 +3,125 @@ import { View, Text, TouchableOpacity, StyleSheet, Pressable } from 'react-nativ
|
|||
import { useTheme, ColorMode, ContrastLevel } from './ThemeProvider';
|
||||
|
||||
const COLOR_MODES: { label: string; value: ColorMode }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
];
|
||||
|
||||
const CONTRAST_LABELS: Record<ContrastLevel, string> = {
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
};
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const {
|
||||
theme,
|
||||
colorMode,
|
||||
setColorMode,
|
||||
contrastLevel,
|
||||
setContrastLevel,
|
||||
} = useTheme();
|
||||
const { theme, colorMode, setColorMode, contrastLevel, setContrastLevel } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Helligkeits-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Helligkeit:
|
||||
</Text>
|
||||
<View style={styles.colorModeList}>
|
||||
{COLOR_MODES.map(mode => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.colorModeOption,
|
||||
{
|
||||
backgroundColor: mode.value === colorMode
|
||||
? `${theme.colors.primary}1A`
|
||||
: theme.colors.backgroundSecondary,
|
||||
borderColor: mode.value === colorMode ? theme.colors.primary : 'transparent',
|
||||
borderWidth: mode.value === colorMode ? 2 : 0,
|
||||
}
|
||||
]}
|
||||
onPress={() => setColorMode(mode.value)}
|
||||
>
|
||||
<Text style={[styles.colorModeText, { color: theme.colors.textPrimary }]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Helligkeits-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Helligkeit:</Text>
|
||||
<View style={styles.colorModeList}>
|
||||
{COLOR_MODES.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.colorModeOption,
|
||||
{
|
||||
backgroundColor:
|
||||
mode.value === colorMode
|
||||
? `${theme.colors.primary}1A`
|
||||
: theme.colors.backgroundSecondary,
|
||||
borderColor: mode.value === colorMode ? theme.colors.primary : 'transparent',
|
||||
borderWidth: mode.value === colorMode ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
onPress={() => setColorMode(mode.value)}
|
||||
>
|
||||
<Text style={[styles.colorModeText, { color: theme.colors.textPrimary }]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Kontrast-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Kontrast:
|
||||
</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor: level === contrastLevel
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
}
|
||||
]}
|
||||
onPress={() => setContrastLevel(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[contrastLevel]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
{/* Kontrast-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor:
|
||||
level === contrastLevel
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setContrastLevel(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[contrastLevel]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorModeList: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
colorModeOption: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorModeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorModeList: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
colorModeOption: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorModeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,52 +1,52 @@
|
|||
export type ThemeVariant = 'default' | 'modern' | 'classic' | 'dark' | 'light';
|
||||
|
||||
export interface Theme {
|
||||
colors: {
|
||||
primary: string;
|
||||
backgroundPage: string;
|
||||
backgroundPrimary: string;
|
||||
backgroundSecondary: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
error: string;
|
||||
success: string;
|
||||
};
|
||||
colors: {
|
||||
primary: string;
|
||||
backgroundPage: string;
|
||||
backgroundPrimary: string;
|
||||
backgroundSecondary: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
error: string;
|
||||
success: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const THEME_NAMES: Record<ThemeVariant, string> = {
|
||||
default: 'Standard',
|
||||
modern: 'Modern',
|
||||
classic: 'Klassisch',
|
||||
dark: 'Dunkel',
|
||||
light: 'Hell',
|
||||
default: 'Standard',
|
||||
modern: 'Modern',
|
||||
classic: 'Klassisch',
|
||||
dark: 'Dunkel',
|
||||
light: 'Hell',
|
||||
};
|
||||
|
||||
const LIGHT_THEME: Theme = {
|
||||
colors: {
|
||||
primary: '#007AFF',
|
||||
backgroundPage: '#F2F2F7',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F2F2F7',
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#6C6C6C',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
},
|
||||
colors: {
|
||||
primary: '#007AFF',
|
||||
backgroundPage: '#F2F2F7',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F2F2F7',
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#6C6C6C',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
},
|
||||
};
|
||||
|
||||
const DARK_THEME: Theme = {
|
||||
colors: {
|
||||
primary: '#0A84FF',
|
||||
backgroundPage: '#000000',
|
||||
backgroundPrimary: '#1C1C1E',
|
||||
backgroundSecondary: '#2C2C2E',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#8E8E93',
|
||||
error: '#FF453A',
|
||||
success: '#32D74B',
|
||||
},
|
||||
colors: {
|
||||
primary: '#0A84FF',
|
||||
backgroundPage: '#000000',
|
||||
backgroundPrimary: '#1C1C1E',
|
||||
backgroundSecondary: '#2C2C2E',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#8E8E93',
|
||||
error: '#FF453A',
|
||||
success: '#32D74B',
|
||||
},
|
||||
};
|
||||
|
||||
export function getTheme(mode: 'light' | 'dark'): Theme {
|
||||
return mode === 'light' ? LIGHT_THEME : DARK_THEME;
|
||||
return mode === 'light' ? LIGHT_THEME : DARK_THEME;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
export interface Deck {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userId: string;
|
||||
sharing: DeckSharing;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userId: string;
|
||||
sharing: DeckSharing;
|
||||
}
|
||||
|
||||
export type CollaboratorRole = 'viewer' | 'editor';
|
||||
|
||||
export interface DeckSharing {
|
||||
isPublic: boolean;
|
||||
collaborators: {
|
||||
[userId: string]: CollaboratorRole;
|
||||
};
|
||||
shareLink?: string;
|
||||
expiresAt?: Date;
|
||||
isPublic: boolean;
|
||||
collaborators: {
|
||||
[userId: string]: CollaboratorRole;
|
||||
};
|
||||
shareLink?: string;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface Slide {
|
||||
id: string;
|
||||
deckId: string;
|
||||
order: number;
|
||||
imageUrl?: string;
|
||||
title: string;
|
||||
fullText?: string;
|
||||
summary?: string;
|
||||
bulletPoints?: string[];
|
||||
notes?: string;
|
||||
altText?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
id: string;
|
||||
deckId: string;
|
||||
order: number;
|
||||
imageUrl?: string;
|
||||
title: string;
|
||||
fullText?: string;
|
||||
summary?: string;
|
||||
bulletPoints?: string[];
|
||||
notes?: string;
|
||||
altText?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
{
|
||||
"name": "@presi/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5178",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@presi/shared": "workspace:*",
|
||||
"lucide-svelte": "^0.460.0"
|
||||
},
|
||||
"type": "module"
|
||||
"name": "@presi/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5178",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@presi/shared": "workspace:*",
|
||||
"lucide-svelte": "^0.460.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,255 +1,263 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
import type { Deck, Slide, CreateDeckDto, UpdateDeckDto, CreateSlideDto, UpdateSlideDto, ReorderSlidesDto } from '@presi/shared';
|
||||
import type {
|
||||
Deck,
|
||||
Slide,
|
||||
CreateDeckDto,
|
||||
UpdateDeckDto,
|
||||
CreateSlideDto,
|
||||
UpdateSlideDto,
|
||||
ReorderSlidesDto,
|
||||
} from '@presi/shared';
|
||||
|
||||
const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3008';
|
||||
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
function getToken(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem('accessToken');
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem('accessToken');
|
||||
}
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const token = getToken();
|
||||
const token = getToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired - try to refresh
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
// Retry the request with new token
|
||||
const newToken = getToken();
|
||||
if (newToken) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
|
||||
}
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
// Clear tokens and redirect to login
|
||||
if (browser) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
if (response.status === 401) {
|
||||
// Token expired - try to refresh
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
// Retry the request with new token
|
||||
const newToken = getToken();
|
||||
if (newToken) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
|
||||
}
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
// Clear tokens and redirect to login
|
||||
if (browser) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
return response;
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
if (!browser) return false;
|
||||
if (!browser) return false;
|
||||
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return false;
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${AUTH_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${AUTH_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
if (data.refreshToken) {
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh token:', e);
|
||||
}
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
if (data.refreshToken) {
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh token:', e);
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
async login(email: string, password: string) {
|
||||
const response = await fetch(`${AUTH_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
async login(email: string, password: string) {
|
||||
const response = await fetch(`${AUTH_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Login failed');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (browser) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
const data = await response.json();
|
||||
if (browser) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
async register(email: string, password: string) {
|
||||
const response = await fetch(`${AUTH_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
async register(email: string, password: string) {
|
||||
const response = await fetch(`${AUTH_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Registration failed');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Registration failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (browser) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
const data = await response.json();
|
||||
if (browser) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
logout() {
|
||||
if (browser) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
if (browser) {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
},
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
if (!browser) return false;
|
||||
return !!localStorage.getItem('accessToken');
|
||||
}
|
||||
isAuthenticated(): boolean {
|
||||
if (!browser) return false;
|
||||
return !!localStorage.getItem('accessToken');
|
||||
},
|
||||
};
|
||||
|
||||
// Decks API
|
||||
export const decksApi = {
|
||||
async getAll(): Promise<Deck[]> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks`);
|
||||
if (!response.ok) throw new Error('Failed to fetch decks');
|
||||
return response.json();
|
||||
},
|
||||
async getAll(): Promise<Deck[]> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks`);
|
||||
if (!response.ok) throw new Error('Failed to fetch decks');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getOne(id: string): Promise<{ deck: Deck; slides: Slide[] }> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch deck');
|
||||
return response.json();
|
||||
},
|
||||
async getOne(id: string): Promise<{ deck: Deck; slides: Slide[] }> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${id}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch deck');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async create(dto: CreateDeckDto): Promise<Deck> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create deck');
|
||||
return response.json();
|
||||
},
|
||||
async create(dto: CreateDeckDto): Promise<Deck> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create deck');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async update(id: string, dto: UpdateDeckDto): Promise<Deck> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update deck');
|
||||
return response.json();
|
||||
},
|
||||
async update(id: string, dto: UpdateDeckDto): Promise<Deck> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update deck');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete deck');
|
||||
}
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete deck');
|
||||
},
|
||||
};
|
||||
|
||||
// Slides API
|
||||
export const slidesApi = {
|
||||
async create(deckId: string, dto: CreateSlideDto): Promise<Slide> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${deckId}/slides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create slide');
|
||||
return response.json();
|
||||
},
|
||||
async create(deckId: string, dto: CreateSlideDto): Promise<Slide> {
|
||||
const response = await fetchWithAuth(`${API_URL}/decks/${deckId}/slides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create slide');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async update(id: string, dto: UpdateSlideDto): Promise<Slide> {
|
||||
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update slide');
|
||||
return response.json();
|
||||
},
|
||||
async update(id: string, dto: UpdateSlideDto): Promise<Slide> {
|
||||
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update slide');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete slide');
|
||||
},
|
||||
async delete(id: string): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete slide');
|
||||
},
|
||||
|
||||
async reorder(dto: ReorderSlidesDto): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/slides/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to reorder slides');
|
||||
}
|
||||
async reorder(dto: ReorderSlidesDto): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/slides/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(dto),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to reorder slides');
|
||||
},
|
||||
};
|
||||
|
||||
// Share API
|
||||
export interface ShareLink {
|
||||
id: string;
|
||||
deckId: string;
|
||||
shareCode: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
deckId: string;
|
||||
shareCode: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
// Public - no auth required
|
||||
async getByCode(code: string): Promise<{ deck: any; slides: any[] }> {
|
||||
const response = await fetch(`${API_URL}/share/${code}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Shared deck not found or link has expired');
|
||||
}
|
||||
throw new Error('Failed to fetch shared deck');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
// Public - no auth required
|
||||
async getByCode(code: string): Promise<{ deck: any; slides: any[] }> {
|
||||
const response = await fetch(`${API_URL}/share/${code}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Shared deck not found or link has expired');
|
||||
}
|
||||
throw new Error('Failed to fetch shared deck');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Authenticated endpoints
|
||||
async createShare(deckId: string, expiresAt?: string): Promise<ShareLink> {
|
||||
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expiresAt })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create share link');
|
||||
return response.json();
|
||||
},
|
||||
// Authenticated endpoints
|
||||
async createShare(deckId: string, expiresAt?: string): Promise<ShareLink> {
|
||||
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expiresAt }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create share link');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getSharesForDeck(deckId: string): Promise<ShareLink[]> {
|
||||
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}/links`);
|
||||
if (!response.ok) throw new Error('Failed to get share links');
|
||||
return response.json();
|
||||
},
|
||||
async getSharesForDeck(deckId: string): Promise<ShareLink[]> {
|
||||
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}/links`);
|
||||
if (!response.ok) throw new Error('Failed to get share links');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async deleteShare(shareId: string): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/share/${shareId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete share link');
|
||||
}
|
||||
async deleteShare(shareId: string): Promise<void> {
|
||||
const response = await fetchWithAuth(`${API_URL}/share/${shareId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete share link');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,68 +2,74 @@ import { browser } from '$app/environment';
|
|||
import { authApi } from '$lib/api/client';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
let isAuthenticated = $state(false);
|
||||
let user = $state<User | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let isAuthenticated = $state(false);
|
||||
let user = $state<User | null>(null);
|
||||
let isLoading = $state(true);
|
||||
|
||||
function init() {
|
||||
if (!browser) {
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
function init() {
|
||||
if (!browser) {
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
// Decode JWT to get user info
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
user = { id: payload.sub, email: payload.email };
|
||||
isAuthenticated = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to decode token:', e);
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
// Decode JWT to get user info
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
user = { id: payload.sub, email: payload.email };
|
||||
isAuthenticated = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to decode token:', e);
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
}
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const data = await authApi.login(email, password);
|
||||
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
||||
user = { id: payload.sub, email: payload.email };
|
||||
isAuthenticated = true;
|
||||
return data;
|
||||
}
|
||||
async function login(email: string, password: string) {
|
||||
const data = await authApi.login(email, password);
|
||||
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
||||
user = { id: payload.sub, email: payload.email };
|
||||
isAuthenticated = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
const data = await authApi.register(email, password);
|
||||
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
||||
user = { id: payload.sub, email: payload.email };
|
||||
isAuthenticated = true;
|
||||
return data;
|
||||
}
|
||||
async function register(email: string, password: string) {
|
||||
const data = await authApi.register(email, password);
|
||||
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
||||
user = { id: payload.sub, email: payload.email };
|
||||
isAuthenticated = true;
|
||||
return data;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authApi.logout();
|
||||
user = null;
|
||||
isAuthenticated = false;
|
||||
}
|
||||
function logout() {
|
||||
authApi.logout();
|
||||
user = null;
|
||||
isAuthenticated = false;
|
||||
}
|
||||
|
||||
return {
|
||||
get isAuthenticated() { return isAuthenticated; },
|
||||
get user() { return user; },
|
||||
get isLoading() { return isLoading; },
|
||||
init,
|
||||
login,
|
||||
register,
|
||||
logout
|
||||
};
|
||||
return {
|
||||
get isAuthenticated() {
|
||||
return isAuthenticated;
|
||||
},
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
init,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
|
|
|
|||
|
|
@ -1,168 +1,185 @@
|
|||
import { decksApi, slidesApi } from '$lib/api/client';
|
||||
import type { Deck, Slide, CreateDeckDto, UpdateDeckDto, CreateSlideDto, UpdateSlideDto } from '@presi/shared';
|
||||
import type {
|
||||
Deck,
|
||||
Slide,
|
||||
CreateDeckDto,
|
||||
UpdateDeckDto,
|
||||
CreateSlideDto,
|
||||
UpdateSlideDto,
|
||||
} from '@presi/shared';
|
||||
|
||||
function createDecksStore() {
|
||||
let decks = $state<Deck[]>([]);
|
||||
let currentDeck = $state<Deck | null>(null);
|
||||
let currentSlides = $state<Slide[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let decks = $state<Deck[]>([]);
|
||||
let currentDeck = $state<Deck | null>(null);
|
||||
let currentSlides = $state<Slide[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function loadDecks() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
decks = await decksApi.getAll();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load decks';
|
||||
console.error('Failed to load decks:', e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function loadDecks() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
decks = await decksApi.getAll();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load decks';
|
||||
console.error('Failed to load decks:', e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeck(id: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await decksApi.getOne(id);
|
||||
currentDeck = data.deck;
|
||||
currentSlides = data.slides.sort((a, b) => a.order - b.order);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load deck';
|
||||
console.error('Failed to load deck:', e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function loadDeck(id: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await decksApi.getOne(id);
|
||||
currentDeck = data.deck;
|
||||
currentSlides = data.slides.sort((a, b) => a.order - b.order);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load deck';
|
||||
console.error('Failed to load deck:', e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeck(dto: CreateDeckDto): Promise<Deck | null> {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const deck = await decksApi.create(dto);
|
||||
decks = [deck, ...decks];
|
||||
return deck;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create deck';
|
||||
console.error('Failed to create deck:', e);
|
||||
return null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function createDeck(dto: CreateDeckDto): Promise<Deck | null> {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const deck = await decksApi.create(dto);
|
||||
decks = [deck, ...decks];
|
||||
return deck;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create deck';
|
||||
console.error('Failed to create deck:', e);
|
||||
return null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDeck(id: string, dto: UpdateDeckDto): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await decksApi.update(id, dto);
|
||||
decks = decks.map(d => d.id === id ? updated : d);
|
||||
if (currentDeck?.id === id) {
|
||||
currentDeck = updated;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update deck';
|
||||
console.error('Failed to update deck:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function updateDeck(id: string, dto: UpdateDeckDto): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await decksApi.update(id, dto);
|
||||
decks = decks.map((d) => (d.id === id ? updated : d));
|
||||
if (currentDeck?.id === id) {
|
||||
currentDeck = updated;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update deck';
|
||||
console.error('Failed to update deck:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDeck(id: string): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
await decksApi.delete(id);
|
||||
decks = decks.filter(d => d.id !== id);
|
||||
if (currentDeck?.id === id) {
|
||||
currentDeck = null;
|
||||
currentSlides = [];
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete deck';
|
||||
console.error('Failed to delete deck:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function deleteDeck(id: string): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
await decksApi.delete(id);
|
||||
decks = decks.filter((d) => d.id !== id);
|
||||
if (currentDeck?.id === id) {
|
||||
currentDeck = null;
|
||||
currentSlides = [];
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete deck';
|
||||
console.error('Failed to delete deck:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
|
||||
error = null;
|
||||
try {
|
||||
const slide = await slidesApi.create(deckId, dto);
|
||||
currentSlides = [...currentSlides, slide].sort((a, b) => a.order - b.order);
|
||||
return slide;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create slide';
|
||||
console.error('Failed to create slide:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
|
||||
error = null;
|
||||
try {
|
||||
const slide = await slidesApi.create(deckId, dto);
|
||||
currentSlides = [...currentSlides, slide].sort((a, b) => a.order - b.order);
|
||||
return slide;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create slide';
|
||||
console.error('Failed to create slide:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSlide(id: string, dto: UpdateSlideDto): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await slidesApi.update(id, dto);
|
||||
currentSlides = currentSlides.map(s => s.id === id ? updated : s);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update slide';
|
||||
console.error('Failed to update slide:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function updateSlide(id: string, dto: UpdateSlideDto): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
const updated = await slidesApi.update(id, dto);
|
||||
currentSlides = currentSlides.map((s) => (s.id === id ? updated : s));
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update slide';
|
||||
console.error('Failed to update slide:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSlide(id: string): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
await slidesApi.delete(id);
|
||||
currentSlides = currentSlides.filter(s => s.id !== id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete slide';
|
||||
console.error('Failed to delete slide:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function deleteSlide(id: string): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
await slidesApi.delete(id);
|
||||
currentSlides = currentSlides.filter((s) => s.id !== id);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete slide';
|
||||
console.error('Failed to delete slide:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
await slidesApi.reorder({ slides });
|
||||
// Update local state
|
||||
const orderMap = new Map(slides.map(s => [s.id, s.order]));
|
||||
currentSlides = currentSlides
|
||||
.map(s => ({ ...s, order: orderMap.get(s.id) ?? s.order }))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder slides';
|
||||
console.error('Failed to reorder slides:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
|
||||
error = null;
|
||||
try {
|
||||
await slidesApi.reorder({ slides });
|
||||
// Update local state
|
||||
const orderMap = new Map(slides.map((s) => [s.id, s.order]));
|
||||
currentSlides = currentSlides
|
||||
.map((s) => ({ ...s, order: orderMap.get(s.id) ?? s.order }))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder slides';
|
||||
console.error('Failed to reorder slides:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCurrent() {
|
||||
currentDeck = null;
|
||||
currentSlides = [];
|
||||
}
|
||||
function clearCurrent() {
|
||||
currentDeck = null;
|
||||
currentSlides = [];
|
||||
}
|
||||
|
||||
return {
|
||||
get decks() { return decks; },
|
||||
get currentDeck() { return currentDeck; },
|
||||
get currentSlides() { return currentSlides; },
|
||||
get isLoading() { return isLoading; },
|
||||
get error() { return error; },
|
||||
loadDecks,
|
||||
loadDeck,
|
||||
createDeck,
|
||||
updateDeck,
|
||||
deleteDeck,
|
||||
createSlide,
|
||||
updateSlide,
|
||||
deleteSlide,
|
||||
reorderSlides,
|
||||
clearCurrent
|
||||
};
|
||||
return {
|
||||
get decks() {
|
||||
return decks;
|
||||
},
|
||||
get currentDeck() {
|
||||
return currentDeck;
|
||||
},
|
||||
get currentSlides() {
|
||||
return currentSlides;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
loadDecks,
|
||||
loadDeck,
|
||||
createDeck,
|
||||
updateDeck,
|
||||
deleteDeck,
|
||||
createSlide,
|
||||
updateSlide,
|
||||
deleteSlide,
|
||||
reorderSlides,
|
||||
clearCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
export const decksStore = createDecksStore();
|
||||
|
|
|
|||
|
|
@ -1,102 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { Presentation, LogOut, Settings, User, Sun, Moon } from 'lucide-svelte';
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { Presentation, LogOut, Settings, User, Sun, Moon } from 'lucide-svelte';
|
||||
import '../app.css';
|
||||
|
||||
let isDark = $state(false);
|
||||
let isDark = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
isDark = localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
});
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
isDark =
|
||||
localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
isDark = !isDark;
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
function toggleTheme() {
|
||||
isDark = !isDark;
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
// Public routes that don't require auth
|
||||
const publicRoutes = ['/login', '/register', '/forgot-password'];
|
||||
$effect(() => {
|
||||
if (!auth.isLoading && !auth.isAuthenticated && !publicRoutes.includes($page.url.pathname)) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
// Public routes that don't require auth
|
||||
const publicRoutes = ['/login', '/register', '/forgot-password'];
|
||||
$effect(() => {
|
||||
if (!auth.isLoading && !auth.isAuthenticated && !publicRoutes.includes($page.url.pathname)) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Presi - Presentation Creator</title>
|
||||
<title>Presi - Presentation Creator</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if auth.isLoading}
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if auth.isAuthenticated || publicRoutes.includes($page.url.pathname)}
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{#if auth.isAuthenticated && !$page.url.pathname.startsWith('/present/')}
|
||||
<header class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16 items-center">
|
||||
<a href="/" class="flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-white">
|
||||
<Presentation class="w-6 h-6 text-primary-500" />
|
||||
Presi
|
||||
</a>
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{#if auth.isAuthenticated && !$page.url.pathname.startsWith('/present/')}
|
||||
<header
|
||||
class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16 items-center">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-white"
|
||||
>
|
||||
<Presentation class="w-6 h-6 text-primary-500" />
|
||||
Presi
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{#if isDark}
|
||||
<Sun class="w-5 h-5 text-slate-600 dark:text-slate-300" />
|
||||
{:else}
|
||||
<Moon class="w-5 h-5 text-slate-600 dark:text-slate-300" />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{#if isDark}
|
||||
<Sun class="w-5 h-5 text-slate-600 dark:text-slate-300" />
|
||||
{:else}
|
||||
<Moon class="w-5 h-5 text-slate-600 dark:text-slate-300" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/settings"
|
||||
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Settings class="w-5 h-5 text-slate-600 dark:text-slate-300" />
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Settings class="w-5 h-5 text-slate-600 dark:text-slate-300" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/profile"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
<User class="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200">{auth.user?.email}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
<User class="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
<span class="text-sm text-slate-700 dark:text-slate-200">{auth.user?.email}</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors group"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<LogOut class="w-5 h-5 text-slate-600 dark:text-slate-300 group-hover:text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors group"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<LogOut
|
||||
class="w-5 h-5 text-slate-600 dark:text-slate-300 group-hover:text-red-600"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,215 +1,237 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { decksStore } from '$lib/stores/decks.svelte';
|
||||
import { Plus, Presentation, Trash2, MoreVertical, Clock, Layers } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { decksStore } from '$lib/stores/decks.svelte';
|
||||
import { Plus, Presentation, Trash2, MoreVertical, Clock, Layers } from 'lucide-svelte';
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let deckToDelete = $state<{ id: string; title: string } | null>(null);
|
||||
let newDeckTitle = $state('');
|
||||
let newDeckDescription = $state('');
|
||||
let isCreating = $state(false);
|
||||
let showCreateModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let deckToDelete = $state<{ id: string; title: string } | null>(null);
|
||||
let newDeckTitle = $state('');
|
||||
let newDeckDescription = $state('');
|
||||
let isCreating = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
decksStore.loadDecks();
|
||||
});
|
||||
onMount(() => {
|
||||
decksStore.loadDecks();
|
||||
});
|
||||
|
||||
async function handleCreateDeck(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newDeckTitle.trim()) return;
|
||||
async function handleCreateDeck(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newDeckTitle.trim()) return;
|
||||
|
||||
isCreating = true;
|
||||
const deck = await decksStore.createDeck({
|
||||
title: newDeckTitle.trim(),
|
||||
description: newDeckDescription.trim() || undefined
|
||||
});
|
||||
isCreating = true;
|
||||
const deck = await decksStore.createDeck({
|
||||
title: newDeckTitle.trim(),
|
||||
description: newDeckDescription.trim() || undefined,
|
||||
});
|
||||
|
||||
if (deck) {
|
||||
showCreateModal = false;
|
||||
newDeckTitle = '';
|
||||
newDeckDescription = '';
|
||||
goto(`/deck/${deck.id}`);
|
||||
}
|
||||
isCreating = false;
|
||||
}
|
||||
if (deck) {
|
||||
showCreateModal = false;
|
||||
newDeckTitle = '';
|
||||
newDeckDescription = '';
|
||||
goto(`/deck/${deck.id}`);
|
||||
}
|
||||
isCreating = false;
|
||||
}
|
||||
|
||||
function confirmDelete(deck: { id: string; title: string }) {
|
||||
deckToDelete = deck;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
function confirmDelete(deck: { id: string; title: string }) {
|
||||
deckToDelete = deck;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deckToDelete) return;
|
||||
await decksStore.deleteDeck(deckToDelete.id);
|
||||
showDeleteModal = false;
|
||||
deckToDelete = null;
|
||||
}
|
||||
async function handleDelete() {
|
||||
if (!deckToDelete) return;
|
||||
await decksStore.deleteDeck(deckToDelete.id);
|
||||
showDeleteModal = false;
|
||||
deckToDelete = null;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>My Decks - Presi</title>
|
||||
<title>My Decks - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">My Presentations</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">Create and manage your slide decks</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => showCreateModal = true}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-5 h-5" />
|
||||
New Deck
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">My Presentations</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">Create and manage your slide decks</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showCreateModal = true)}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-5 h-5" />
|
||||
New Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if decksStore.isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
{:else if decksStore.decks.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4">
|
||||
<Presentation class="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<h2 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No presentations yet</h2>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-4">Create your first deck to get started</p>
|
||||
<button
|
||||
onclick={() => showCreateModal = true}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-5 h-5" />
|
||||
Create Deck
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{#each decksStore.decks as deck (deck.id)}
|
||||
<div class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow">
|
||||
<a href="/deck/{deck.id}" class="block">
|
||||
<div class="aspect-video bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<Presentation class="w-12 h-12 text-white/80" />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-slate-900 dark:text-white truncate">{deck.title}</h3>
|
||||
{#if deck.description}
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mt-1 line-clamp-2">{deck.description}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<Clock class="w-3.5 h-3.5" />
|
||||
{formatDate(deck.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="px-4 pb-4 flex justify-end">
|
||||
<button
|
||||
onclick={(e) => { e.preventDefault(); confirmDelete({ id: deck.id, title: deck.title }); }}
|
||||
class="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
aria-label="Delete deck"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if decksStore.isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div
|
||||
class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if decksStore.decks.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<div
|
||||
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
|
||||
>
|
||||
<Presentation class="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<h2 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No presentations yet</h2>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-4">Create your first deck to get started</p>
|
||||
<button
|
||||
onclick={() => (showCreateModal = true)}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-5 h-5" />
|
||||
Create Deck
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{#each decksStore.decks as deck (deck.id)}
|
||||
<div
|
||||
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<a href="/deck/{deck.id}" class="block">
|
||||
<div
|
||||
class="aspect-video bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center"
|
||||
>
|
||||
<Presentation class="w-12 h-12 text-white/80" />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-slate-900 dark:text-white truncate">{deck.title}</h3>
|
||||
{#if deck.description}
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mt-1 line-clamp-2">
|
||||
{deck.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<Clock class="w-3.5 h-3.5" />
|
||||
{formatDate(deck.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="px-4 pb-4 flex justify-end">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDelete({ id: deck.id, title: deck.title });
|
||||
}}
|
||||
class="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
aria-label="Delete deck"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create Deck Modal -->
|
||||
{#if showCreateModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md">
|
||||
<form onsubmit={handleCreateDeck}>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-4">Create New Deck</h2>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md">
|
||||
<form onsubmit={handleCreateDeck}>
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-4">Create New Deck</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
bind:value={newDeckTitle}
|
||||
required
|
||||
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="My Presentation"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="title"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
bind:value={newDeckTitle}
|
||||
required
|
||||
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="My Presentation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={newDeckDescription}
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
placeholder="What is this presentation about?"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={newDeckDescription}
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
placeholder="What is this presentation about?"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-xl">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => showCreateModal = false}
|
||||
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating || !newDeckTitle.trim()}
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-xl">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateModal = false)}
|
||||
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating || !newDeckTitle.trim()}
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Delete Deck</h2>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
Are you sure you want to delete "{deckToDelete?.title}"? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => { showDeleteModal = false; deckToDelete = null; }}
|
||||
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Delete Deck</h2>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
Are you sure you want to delete "{deckToDelete?.title}"? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => {
|
||||
showDeleteModal = false;
|
||||
deckToDelete = null;
|
||||
}}
|
||||
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,122 +1,134 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Presentation, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
|
||||
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Presentation, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
|
||||
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
|
||||
let email = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
let resetSent = $state(false);
|
||||
let email = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
let resetSent = $state(false);
|
||||
|
||||
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
|
||||
if (!email.trim()) {
|
||||
error = 'Please enter your email address';
|
||||
return;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
error = 'Please enter your email address';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${AUTH_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${AUTH_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to send reset email');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to send reset email');
|
||||
}
|
||||
|
||||
resetSent = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to send reset email';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
resetSent = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to send reset email';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Forgot Password - Presi</title>
|
||||
<title>Forgot Password - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
|
||||
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{resetSent ? 'Check your email' : 'Reset password'}
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">
|
||||
{resetSent ? `We've sent reset instructions to ${email}` : 'Enter your email to receive reset instructions'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
|
||||
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{resetSent ? 'Check your email' : 'Reset password'}
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">
|
||||
{resetSent
|
||||
? `We've sent reset instructions to ${email}`
|
||||
: 'Enter your email to receive reset instructions'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if resetSent}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<CheckCircle class="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
If an account exists with this email, you'll receive password reset instructions shortly.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit} class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4">
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
{#if resetSent}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||
<CheckCircle class="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
If an account exists with this email, you'll receive password reset instructions shortly.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<div
|
||||
class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset instructions'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset instructions'}
|
||||
</button>
|
||||
|
||||
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium">Back to login</a>
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>Back to login</a
|
||||
>
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,104 +1,119 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
isLoading = true;
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await auth.login(email, password);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Login failed';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await auth.login(email, password);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Login failed';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - Presi</title>
|
||||
<title>Login - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
|
||||
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Welcome back</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">Sign in to your Presi account</p>
|
||||
</div>
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
|
||||
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Welcome back</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">Sign in to your Presi account</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4">
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
|
||||
<AlertCircle class="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<a href="/forgot-password" class="text-slate-600 dark:text-slate-400 hover:text-primary-600">
|
||||
Forgot password?
|
||||
</a>
|
||||
<p class="text-slate-600 dark:text-slate-400">
|
||||
No account?
|
||||
<a href="/register" class="text-primary-600 hover:text-primary-700 font-medium">Sign up</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-slate-600 dark:text-slate-400 hover:text-primary-600"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
<p class="text-slate-600 dark:text-slate-400">
|
||||
No account?
|
||||
<a href="/register" class="text-primary-600 hover:text-primary-700 font-medium">Sign up</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,296 +1,310 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { decksStore } from '$lib/stores/decks.svelte';
|
||||
import type { Slide } from '@presi/shared';
|
||||
import {
|
||||
X, ChevronLeft, ChevronRight, Play, Pause, Eye, EyeOff,
|
||||
Maximize, Minimize, Clock
|
||||
} from 'lucide-svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { decksStore } from '$lib/stores/decks.svelte';
|
||||
import type { Slide } from '@presi/shared';
|
||||
import {
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Clock,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let currentSlideIndex = $state(0);
|
||||
let isFullscreen = $state(false);
|
||||
let showNotes = $state(false);
|
||||
let isTimerRunning = $state(false);
|
||||
let elapsedSeconds = $state(0);
|
||||
let showControls = $state(true);
|
||||
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let currentSlideIndex = $state(0);
|
||||
let isFullscreen = $state(false);
|
||||
let showNotes = $state(false);
|
||||
let isTimerRunning = $state(false);
|
||||
let elapsedSeconds = $state(0);
|
||||
let showControls = $state(true);
|
||||
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const deckId = $page.params.id as string;
|
||||
const deckId = $page.params.id as string;
|
||||
|
||||
onMount(() => {
|
||||
decksStore.loadDeck(deckId);
|
||||
onMount(() => {
|
||||
decksStore.loadDeck(deckId);
|
||||
|
||||
// Keyboard navigation
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
// Keyboard navigation
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
decksStore.clearCurrent();
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
decksStore.clearCurrent();
|
||||
};
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'a':
|
||||
prevSlide();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'd':
|
||||
case ' ':
|
||||
nextSlide();
|
||||
break;
|
||||
case 'Escape':
|
||||
exitPresentation();
|
||||
break;
|
||||
case 'f':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'a':
|
||||
prevSlide();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'd':
|
||||
case ' ':
|
||||
nextSlide();
|
||||
break;
|
||||
case 'Escape':
|
||||
exitPresentation();
|
||||
break;
|
||||
case 'f':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
|
||||
function handleMouseMove() {
|
||||
showControls = true;
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
function handleMouseMove() {
|
||||
showControls = true;
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
|
||||
function resetHideControlsTimer() {
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
hideControlsTimeout = setTimeout(() => {
|
||||
showControls = false;
|
||||
}, 3000);
|
||||
}
|
||||
function resetHideControlsTimer() {
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
hideControlsTimeout = setTimeout(() => {
|
||||
showControls = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function handleFullscreenChange() {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
}
|
||||
function handleFullscreenChange() {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
if (currentSlideIndex > 0) {
|
||||
currentSlideIndex--;
|
||||
}
|
||||
}
|
||||
function prevSlide() {
|
||||
if (currentSlideIndex > 0) {
|
||||
currentSlideIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (currentSlideIndex < decksStore.currentSlides.length - 1) {
|
||||
currentSlideIndex++;
|
||||
}
|
||||
}
|
||||
function nextSlide() {
|
||||
if (currentSlideIndex < decksStore.currentSlides.length - 1) {
|
||||
currentSlideIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
function goToSlide(index: number) {
|
||||
currentSlideIndex = index;
|
||||
}
|
||||
function goToSlide(index: number) {
|
||||
currentSlideIndex = index;
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTimer() {
|
||||
isTimerRunning = !isTimerRunning;
|
||||
if (isTimerRunning) {
|
||||
timerInterval = setInterval(() => {
|
||||
elapsedSeconds++;
|
||||
}, 1000);
|
||||
} else if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
function toggleTimer() {
|
||||
isTimerRunning = !isTimerRunning;
|
||||
if (isTimerRunning) {
|
||||
timerInterval = setInterval(() => {
|
||||
elapsedSeconds++;
|
||||
}, 1000);
|
||||
} else if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function exitPresentation() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
goto(`/deck/${deckId}`);
|
||||
}
|
||||
function exitPresentation() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
goto(`/deck/${deckId}`);
|
||||
}
|
||||
|
||||
const currentSlide = $derived(decksStore.currentSlides[currentSlideIndex]);
|
||||
const currentSlide = $derived(decksStore.currentSlides[currentSlideIndex]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Presenting: {decksStore.currentDeck?.title || 'Loading...'}</title>
|
||||
<title>Presenting: {decksStore.currentDeck?.title || 'Loading...'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
|
||||
{#if decksStore.isLoading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
{:else if currentSlide}
|
||||
<!-- Top Bar -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-medium truncate max-w-xs">{decksStore.currentDeck?.title}</h1>
|
||||
<span class="text-sm text-slate-400">
|
||||
Slide {currentSlideIndex + 1} of {decksStore.currentSlides.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={exitPresentation}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Exit presentation"
|
||||
>
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
{#if decksStore.isLoading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if currentSlide}
|
||||
<!-- Top Bar -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-medium truncate max-w-xs">{decksStore.currentDeck?.title}</h1>
|
||||
<span class="text-sm text-slate-400">
|
||||
Slide {currentSlideIndex + 1} of {decksStore.currentSlides.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={exitPresentation}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label="Exit presentation"
|
||||
>
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Slide Area -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
|
||||
<div class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12">
|
||||
{#if currentSlide.content.imageUrl}
|
||||
<img
|
||||
src={currentSlide.content.imageUrl}
|
||||
alt={currentSlide.content.title || 'Slide image'}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-center max-w-4xl">
|
||||
{#if currentSlide.content.title}
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8">{currentSlide.content.title}</h2>
|
||||
{/if}
|
||||
{#if currentSlide.content.body}
|
||||
<p class="text-xl md:text-2xl text-slate-300 mb-8">{currentSlide.content.body}</p>
|
||||
{/if}
|
||||
{#if currentSlide.content.bulletPoints?.length}
|
||||
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
|
||||
{#each currentSlide.content.bulletPoints as point}
|
||||
<li class="flex items-start gap-4">
|
||||
<span class="text-primary-400 mt-1">•</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Slide Area -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
|
||||
<div
|
||||
class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12"
|
||||
>
|
||||
{#if currentSlide.content.imageUrl}
|
||||
<img
|
||||
src={currentSlide.content.imageUrl}
|
||||
alt={currentSlide.content.title || 'Slide image'}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-center max-w-4xl">
|
||||
{#if currentSlide.content.title}
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8">
|
||||
{currentSlide.content.title}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if currentSlide.content.body}
|
||||
<p class="text-xl md:text-2xl text-slate-300 mb-8">{currentSlide.content.body}</p>
|
||||
{/if}
|
||||
{#if currentSlide.content.bulletPoints?.length}
|
||||
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
|
||||
{#each currentSlide.content.bulletPoints as point}
|
||||
<li class="flex items-start gap-4">
|
||||
<span class="text-primary-400 mt-1">•</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speaker Notes -->
|
||||
{#if showNotes && currentSlide.content.subtitle}
|
||||
<div class="absolute bottom-32 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4">
|
||||
<div class="bg-slate-800/90 rounded-lg p-4 backdrop-blur-sm">
|
||||
<h3 class="text-sm font-medium text-slate-400 mb-2">Speaker Notes</h3>
|
||||
<p class="text-slate-200">{currentSlide.content.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Speaker Notes -->
|
||||
{#if showNotes && currentSlide.content.subtitle}
|
||||
<div class="absolute bottom-32 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4">
|
||||
<div class="bg-slate-800/90 rounded-lg p-4 backdrop-blur-sm">
|
||||
<h3 class="text-sm font-medium text-slate-400 mb-2">Speaker Notes</h3>
|
||||
<p class="text-slate-200">{currentSlide.content.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom Controls -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<!-- Left: Timer -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={toggleTimer}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isTimerRunning ? 'Pause timer' : 'Start timer'}
|
||||
>
|
||||
{#if isTimerRunning}
|
||||
<Pause class="w-5 h-5" />
|
||||
{:else}
|
||||
<Play class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-slate-300">
|
||||
<Clock class="w-4 h-4" />
|
||||
<span class="font-mono">{formatTime(elapsedSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom Controls -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<!-- Left: Timer -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={toggleTimer}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isTimerRunning ? 'Pause timer' : 'Start timer'}
|
||||
>
|
||||
{#if isTimerRunning}
|
||||
<Pause class="w-5 h-5" />
|
||||
{:else}
|
||||
<Play class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-slate-300">
|
||||
<Clock class="w-4 h-4" />
|
||||
<span class="font-mono">{formatTime(elapsedSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevSlide}
|
||||
disabled={currentSlideIndex === 0}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft class="w-6 h-6" />
|
||||
</button>
|
||||
<!-- Center: Navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevSlide}
|
||||
disabled={currentSlideIndex === 0}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Slide Dots -->
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
{#each decksStore.currentSlides as _, index}
|
||||
<button
|
||||
onclick={() => goToSlide(index)}
|
||||
class="w-2 h-2 rounded-full transition-all"
|
||||
class:bg-primary-500={index === currentSlideIndex}
|
||||
class:w-4={index === currentSlideIndex}
|
||||
class:bg-slate-500={index !== currentSlideIndex}
|
||||
aria-label="Go to slide {index + 1}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Slide Dots -->
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
{#each decksStore.currentSlides as _, index}
|
||||
<button
|
||||
onclick={() => goToSlide(index)}
|
||||
class="w-2 h-2 rounded-full transition-all"
|
||||
class:bg-primary-500={index === currentSlideIndex}
|
||||
class:w-4={index === currentSlideIndex}
|
||||
class:bg-slate-500={index !== currentSlideIndex}
|
||||
aria-label="Go to slide {index + 1}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={nextSlide}
|
||||
disabled={currentSlideIndex === decksStore.currentSlides.length - 1}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={nextSlide}
|
||||
disabled={currentSlideIndex === decksStore.currentSlides.length - 1}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right: Options -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => showNotes = !showNotes}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={showNotes ? 'Hide notes' : 'Show notes'}
|
||||
>
|
||||
{#if showNotes}
|
||||
<EyeOff class="w-5 h-5" />
|
||||
{:else}
|
||||
<Eye class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{#if isFullscreen}
|
||||
<Minimize class="w-5 h-5" />
|
||||
{:else}
|
||||
<Maximize class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<p class="text-slate-400">No slides in this deck</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Right: Options -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (showNotes = !showNotes)}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={showNotes ? 'Hide notes' : 'Show notes'}
|
||||
>
|
||||
{#if showNotes}
|
||||
<EyeOff class="w-5 h-5" />
|
||||
{:else}
|
||||
<Eye class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{#if isFullscreen}
|
||||
<Minimize class="w-5 h-5" />
|
||||
{:else}
|
||||
<Maximize class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<p class="text-slate-400">No slides in this deck</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,141 +1,146 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { decksStore } from '$lib/stores/decks.svelte';
|
||||
import { User, FolderOpen, Layers, Calendar, ArrowLeft } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { decksStore } from '$lib/stores/decks.svelte';
|
||||
import { User, FolderOpen, Layers, Calendar, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let totalSlides = $state(0);
|
||||
let isLoading = $state(true);
|
||||
let totalSlides = $state(0);
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await decksStore.loadDecks();
|
||||
onMount(async () => {
|
||||
await decksStore.loadDecks();
|
||||
|
||||
// Calculate total slides from all decks
|
||||
let slides = 0;
|
||||
for (const deck of decksStore.decks) {
|
||||
// Load each deck to get slide count
|
||||
// Note: This is a simplified approach - in production you might want an API endpoint for stats
|
||||
}
|
||||
// Calculate total slides from all decks
|
||||
let slides = 0;
|
||||
for (const deck of decksStore.decks) {
|
||||
// Load each deck to get slide count
|
||||
// Note: This is a simplified approach - in production you might want an API endpoint for stats
|
||||
}
|
||||
|
||||
// For now, we show deck count - slide count would require loading all decks
|
||||
isLoading = false;
|
||||
});
|
||||
// For now, we show deck count - slide count would require loading all decks
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile - Presi</title>
|
||||
<title>Profile - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<a
|
||||
href="/"
|
||||
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft class="w-5 h-5 text-slate-600 dark:text-slate-400" />
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Profile</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<a href="/" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors">
|
||||
<ArrowLeft class="w-5 h-5 text-slate-600 dark:text-slate-400" />
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Profile</h1>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- User Info Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="p-8 text-center">
|
||||
<div class="mx-auto w-20 h-20 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<User class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{auth.user?.email || 'User'}
|
||||
</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1 font-mono">
|
||||
ID: {auth.user?.id?.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div
|
||||
class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- User Info Card -->
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-8 text-center">
|
||||
<div
|
||||
class="mx-auto w-20 h-20 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center mb-4"
|
||||
>
|
||||
<User class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{auth.user?.email || 'User'}
|
||||
</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1 font-mono">
|
||||
ID: {auth.user?.id?.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Statistics</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Total Decks -->
|
||||
<div class="text-center p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
||||
<div class="flex justify-center mb-2">
|
||||
<FolderOpen class="w-8 h-8 text-primary-500" />
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{decksStore.decks.length}
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-400 mt-1">
|
||||
Total Decks
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Card -->
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Statistics</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- Total Decks -->
|
||||
<div class="text-center p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
||||
<div class="flex justify-center mb-2">
|
||||
<FolderOpen class="w-8 h-8 text-primary-500" />
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{decksStore.decks.length}
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-400 mt-1">Total Decks</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Slides -->
|
||||
<div class="text-center p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
||||
<div class="flex justify-center mb-2">
|
||||
<Layers class="w-8 h-8 text-primary-500" />
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
-
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-400 mt-1">
|
||||
Total Slides
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Total Slides -->
|
||||
<div class="text-center p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
||||
<div class="flex justify-center mb-2">
|
||||
<Layers class="w-8 h-8 text-primary-500" />
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-slate-900 dark:text-white">-</div>
|
||||
<div class="text-sm text-slate-600 dark:text-slate-400 mt-1">Total Slides</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
{#if decksStore.decks.length > 0}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Recent Presentations</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{#each decksStore.decks.slice(0, 5) as deck (deck.id)}
|
||||
<a
|
||||
href="/deck/{deck.id}"
|
||||
class="flex items-center justify-between p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
|
||||
<FolderOpen class="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-900 dark:text-white">{deck.title}</h4>
|
||||
{#if deck.description}
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 truncate max-w-xs">
|
||||
{deck.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Calendar class="w-4 h-4" />
|
||||
{formatDate(deck.updatedAt)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Recent Activity -->
|
||||
{#if decksStore.decks.length > 0}
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
Recent Presentations
|
||||
</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{#each decksStore.decks.slice(0, 5) as deck (deck.id)}
|
||||
<a
|
||||
href="/deck/{deck.id}"
|
||||
class="flex items-center justify-between p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<FolderOpen class="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-900 dark:text-white">{deck.title}</h4>
|
||||
{#if deck.description}
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 truncate max-w-xs">
|
||||
{deck.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Calendar class="w-4 h-4" />
|
||||
{formatDate(deck.updatedAt)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,128 +1,142 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let isLoading = $state(false);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
error = 'Password must be at least 6 characters';
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
error = 'Password must be at least 6 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
await auth.register(email, password);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Registration failed';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await auth.register(email, password);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Registration failed';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - Presi</title>
|
||||
<title>Register - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
|
||||
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Create account</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">Start creating amazing presentations</p>
|
||||
</div>
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
|
||||
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Create account</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 mt-1">Start creating amazing presentations</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4">
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
|
||||
<AlertCircle class="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
Already have an account?
|
||||
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium">Sign in</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
|
||||
Already have an account?
|
||||
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium">Sign in</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,133 +1,150 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { User, Mail, Shield, LogOut, Sun, Moon, Monitor } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { User, Mail, Shield, LogOut, Sun, Moon, Monitor } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
let themeMode = $state<ThemeMode>('system');
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
let themeMode = $state<ThemeMode>('system');
|
||||
|
||||
onMount(() => {
|
||||
const saved = localStorage.getItem('theme') as ThemeMode | null;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
themeMode = saved;
|
||||
} else {
|
||||
themeMode = 'system';
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
const saved = localStorage.getItem('theme') as ThemeMode | null;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
themeMode = saved;
|
||||
} else {
|
||||
themeMode = 'system';
|
||||
}
|
||||
});
|
||||
|
||||
function setTheme(mode: ThemeMode) {
|
||||
themeMode = mode;
|
||||
if (mode === 'system') {
|
||||
localStorage.removeItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', prefersDark);
|
||||
} else {
|
||||
localStorage.setItem('theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
}
|
||||
}
|
||||
function setTheme(mode: ThemeMode) {
|
||||
themeMode = mode;
|
||||
if (mode === 'system') {
|
||||
localStorage.removeItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.toggle('dark', prefersDark);
|
||||
} else {
|
||||
localStorage.setItem('theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - Presi</title>
|
||||
<title>Settings - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white mb-8">Settings</h1>
|
||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-white mb-8">Settings</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Account Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<User class="w-5 h-5 text-slate-400" />
|
||||
Account
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<Mail class="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Email</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{auth.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<Shield class="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">User ID</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 font-mono">{auth.user?.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<!-- Account Section -->
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<User class="w-5 h-5 text-slate-400" />
|
||||
Account
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<Mail class="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Email</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">{auth.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<Shield class="w-5 h-5 text-slate-400" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">User ID</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 font-mono">{auth.user?.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Sun class="w-5 h-5 text-slate-400" />
|
||||
Appearance
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">Choose your preferred theme</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onclick={() => setTheme('light')}
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors {themeMode === 'light' ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30' : 'border-slate-200 dark:border-slate-600'}"
|
||||
>
|
||||
<Sun class="w-6 h-6 text-amber-500" />
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Light</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => setTheme('dark')}
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors {themeMode === 'dark' ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30' : 'border-slate-200 dark:border-slate-600'}"
|
||||
>
|
||||
<Moon class="w-6 h-6 text-indigo-500" />
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Dark</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => setTheme('system')}
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors {themeMode === 'system' ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30' : 'border-slate-200 dark:border-slate-600'}"
|
||||
>
|
||||
<Monitor class="w-6 h-6 text-slate-500" />
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Appearance Section -->
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<Sun class="w-5 h-5 text-slate-400" />
|
||||
Appearance
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">Choose your preferred theme</p>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onclick={() => setTheme('light')}
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors {themeMode ===
|
||||
'light'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-slate-200 dark:border-slate-600'}"
|
||||
>
|
||||
<Sun class="w-6 h-6 text-amber-500" />
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Light</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => setTheme('dark')}
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors {themeMode ===
|
||||
'dark'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-slate-200 dark:border-slate-600'}"
|
||||
>
|
||||
<Moon class="w-6 h-6 text-indigo-500" />
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Dark</span>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => setTheme('system')}
|
||||
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors {themeMode ===
|
||||
'system'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-slate-200 dark:border-slate-600'}"
|
||||
>
|
||||
<Monitor class="w-6 h-6 text-slate-500" />
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">System</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-red-200 dark:border-red-900/50 overflow-hidden">
|
||||
<div class="p-4 border-b border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/20">
|
||||
<h2 class="text-lg font-semibold text-red-700 dark:text-red-400">Danger Zone</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Sign out</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">Sign out of your account on this device</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Danger Zone -->
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-red-200 dark:border-red-900/50 overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/20">
|
||||
<h2 class="text-lg font-semibold text-red-700 dark:text-red-400">Danger Zone</h2>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Sign out</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
Sign out of your account on this device
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,301 +1,314 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { shareApi } from '$lib/api/client';
|
||||
import type { Slide } from '@presi/shared';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Play, Pause,
|
||||
Maximize, Minimize, Clock, Presentation, AlertCircle
|
||||
} from 'lucide-svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { shareApi } from '$lib/api/client';
|
||||
import type { Slide } from '@presi/shared';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Pause,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Clock,
|
||||
Presentation,
|
||||
AlertCircle,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let deck = $state<any>(null);
|
||||
let slides = $state<Slide[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state('');
|
||||
let currentSlideIndex = $state(0);
|
||||
let isFullscreen = $state(false);
|
||||
let isTimerRunning = $state(false);
|
||||
let elapsedSeconds = $state(0);
|
||||
let showControls = $state(true);
|
||||
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let deck = $state<any>(null);
|
||||
let slides = $state<Slide[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state('');
|
||||
let currentSlideIndex = $state(0);
|
||||
let isFullscreen = $state(false);
|
||||
let isTimerRunning = $state(false);
|
||||
let elapsedSeconds = $state(0);
|
||||
let showControls = $state(true);
|
||||
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const shareCode = $page.params.code as string;
|
||||
const shareCode = $page.params.code as string;
|
||||
|
||||
async function loadSharedDeck() {
|
||||
try {
|
||||
const data = await shareApi.getByCode(shareCode);
|
||||
deck = data;
|
||||
slides = data.slides || [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load shared deck';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
async function loadSharedDeck() {
|
||||
try {
|
||||
const data = await shareApi.getByCode(shareCode);
|
||||
deck = data;
|
||||
slides = data.slides || [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load shared deck';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSharedDeck();
|
||||
onMount(() => {
|
||||
loadSharedDeck();
|
||||
|
||||
// Keyboard navigation
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
// Keyboard navigation
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
};
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'a':
|
||||
prevSlide();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'd':
|
||||
case ' ':
|
||||
nextSlide();
|
||||
break;
|
||||
case 'f':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'a':
|
||||
prevSlide();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'd':
|
||||
case ' ':
|
||||
nextSlide();
|
||||
break;
|
||||
case 'f':
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
|
||||
function handleMouseMove() {
|
||||
showControls = true;
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
function handleMouseMove() {
|
||||
showControls = true;
|
||||
resetHideControlsTimer();
|
||||
}
|
||||
|
||||
function resetHideControlsTimer() {
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
hideControlsTimeout = setTimeout(() => {
|
||||
showControls = false;
|
||||
}, 3000);
|
||||
}
|
||||
function resetHideControlsTimer() {
|
||||
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
|
||||
hideControlsTimeout = setTimeout(() => {
|
||||
showControls = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function handleFullscreenChange() {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
}
|
||||
function handleFullscreenChange() {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
if (currentSlideIndex > 0) {
|
||||
currentSlideIndex--;
|
||||
}
|
||||
}
|
||||
function prevSlide() {
|
||||
if (currentSlideIndex > 0) {
|
||||
currentSlideIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (currentSlideIndex < slides.length - 1) {
|
||||
currentSlideIndex++;
|
||||
}
|
||||
}
|
||||
function nextSlide() {
|
||||
if (currentSlideIndex < slides.length - 1) {
|
||||
currentSlideIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
function goToSlide(index: number) {
|
||||
currentSlideIndex = index;
|
||||
}
|
||||
function goToSlide(index: number) {
|
||||
currentSlideIndex = index;
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTimer() {
|
||||
isTimerRunning = !isTimerRunning;
|
||||
if (isTimerRunning) {
|
||||
timerInterval = setInterval(() => {
|
||||
elapsedSeconds++;
|
||||
}, 1000);
|
||||
} else if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
function toggleTimer() {
|
||||
isTimerRunning = !isTimerRunning;
|
||||
if (isTimerRunning) {
|
||||
timerInterval = setInterval(() => {
|
||||
elapsedSeconds++;
|
||||
}, 1000);
|
||||
} else if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const currentSlide = $derived(slides[currentSlideIndex]);
|
||||
const currentSlide = $derived(slides[currentSlideIndex]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{deck?.title || 'Shared Presentation'} - Presi</title>
|
||||
<title>{deck?.title || 'Shared Presentation'} - Presi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
|
||||
{#if isLoading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-4">
|
||||
<div class="p-4 bg-red-900/30 rounded-full mb-4">
|
||||
<AlertCircle class="w-12 h-12 text-red-400" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold mb-2">Unable to load presentation</h1>
|
||||
<p class="text-slate-400 text-center max-w-md mb-6">{error}</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Sign in to Presi
|
||||
</a>
|
||||
</div>
|
||||
{:else if currentSlide}
|
||||
<!-- Top Bar -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-primary-400">
|
||||
<Presentation class="w-5 h-5" />
|
||||
<span class="text-sm font-medium">Presi</span>
|
||||
</div>
|
||||
<h1 class="text-lg font-medium truncate max-w-xs">{deck?.title}</h1>
|
||||
<span class="text-sm text-slate-400">
|
||||
Slide {currentSlideIndex + 1} of {slides.length}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-4">
|
||||
<div class="p-4 bg-red-900/30 rounded-full mb-4">
|
||||
<AlertCircle class="w-12 h-12 text-red-400" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold mb-2">Unable to load presentation</h1>
|
||||
<p class="text-slate-400 text-center max-w-md mb-6">{error}</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Sign in to Presi
|
||||
</a>
|
||||
</div>
|
||||
{:else if currentSlide}
|
||||
<!-- Top Bar -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-primary-400">
|
||||
<Presentation class="w-5 h-5" />
|
||||
<span class="text-sm font-medium">Presi</span>
|
||||
</div>
|
||||
<h1 class="text-lg font-medium truncate max-w-xs">{deck?.title}</h1>
|
||||
<span class="text-sm text-slate-400">
|
||||
Slide {currentSlideIndex + 1} of {slides.length}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Main Slide Area -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
|
||||
<div class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12">
|
||||
{#if currentSlide.content.imageUrl}
|
||||
<img
|
||||
src={currentSlide.content.imageUrl}
|
||||
alt={currentSlide.content.title || 'Slide image'}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-center max-w-4xl">
|
||||
{#if currentSlide.content.title}
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8">{currentSlide.content.title}</h2>
|
||||
{/if}
|
||||
{#if currentSlide.content.body}
|
||||
<p class="text-xl md:text-2xl text-slate-300 mb-8">{currentSlide.content.body}</p>
|
||||
{/if}
|
||||
{#if currentSlide.content.bulletPoints?.length}
|
||||
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
|
||||
{#each currentSlide.content.bulletPoints as point}
|
||||
<li class="flex items-start gap-4">
|
||||
<span class="text-primary-400 mt-1">•</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Main Slide Area -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
|
||||
<div
|
||||
class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12"
|
||||
>
|
||||
{#if currentSlide.content.imageUrl}
|
||||
<img
|
||||
src={currentSlide.content.imageUrl}
|
||||
alt={currentSlide.content.title || 'Slide image'}
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
{:else}
|
||||
<div class="text-center max-w-4xl">
|
||||
{#if currentSlide.content.title}
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8">
|
||||
{currentSlide.content.title}
|
||||
</h2>
|
||||
{/if}
|
||||
{#if currentSlide.content.body}
|
||||
<p class="text-xl md:text-2xl text-slate-300 mb-8">{currentSlide.content.body}</p>
|
||||
{/if}
|
||||
{#if currentSlide.content.bulletPoints?.length}
|
||||
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
|
||||
{#each currentSlide.content.bulletPoints as point}
|
||||
<li class="flex items-start gap-4">
|
||||
<span class="text-primary-400 mt-1">•</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<!-- Left: Timer -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={toggleTimer}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isTimerRunning ? 'Pause timer' : 'Start timer'}
|
||||
>
|
||||
{#if isTimerRunning}
|
||||
<Pause class="w-5 h-5" />
|
||||
{:else}
|
||||
<Play class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-slate-300">
|
||||
<Clock class="w-4 h-4" />
|
||||
<span class="font-mono">{formatTime(elapsedSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom Controls -->
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
>
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<!-- Left: Timer -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onclick={toggleTimer}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isTimerRunning ? 'Pause timer' : 'Start timer'}
|
||||
>
|
||||
{#if isTimerRunning}
|
||||
<Pause class="w-5 h-5" />
|
||||
{:else}
|
||||
<Play class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-slate-300">
|
||||
<Clock class="w-4 h-4" />
|
||||
<span class="font-mono">{formatTime(elapsedSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevSlide}
|
||||
disabled={currentSlideIndex === 0}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft class="w-6 h-6" />
|
||||
</button>
|
||||
<!-- Center: Navigation -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={prevSlide}
|
||||
disabled={currentSlideIndex === 0}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<ChevronLeft class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Slide Dots -->
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
{#each slides as _, index}
|
||||
<button
|
||||
onclick={() => goToSlide(index)}
|
||||
class="w-2 h-2 rounded-full transition-all"
|
||||
class:bg-primary-500={index === currentSlideIndex}
|
||||
class:w-4={index === currentSlideIndex}
|
||||
class:bg-slate-500={index !== currentSlideIndex}
|
||||
aria-label="Go to slide {index + 1}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Slide Dots -->
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
{#each slides as _, index}
|
||||
<button
|
||||
onclick={() => goToSlide(index)}
|
||||
class="w-2 h-2 rounded-full transition-all"
|
||||
class:bg-primary-500={index === currentSlideIndex}
|
||||
class:w-4={index === currentSlideIndex}
|
||||
class:bg-slate-500={index !== currentSlideIndex}
|
||||
aria-label="Go to slide {index + 1}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={nextSlide}
|
||||
disabled={currentSlideIndex === slides.length - 1}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onclick={nextSlide}
|
||||
disabled={currentSlideIndex === slides.length - 1}
|
||||
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<ChevronRight class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right: Fullscreen -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{#if isFullscreen}
|
||||
<Minimize class="w-5 h-5" />
|
||||
{:else}
|
||||
<Maximize class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex flex-col items-center justify-center">
|
||||
<p class="text-slate-400 mb-4">No slides in this presentation</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Sign in to Presi
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Right: Fullscreen -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{#if isFullscreen}
|
||||
<Minimize class="w-5 h-5" />
|
||||
{:else}
|
||||
<Maximize class="w-5 h-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex flex-col items-center justify-center">
|
||||
<p class="text-slate-400 mb-4">No slides in this presentation</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Sign in to Presi
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$lib: './src/lib'
|
||||
}
|
||||
}
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$lib: './src/lib',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49'
|
||||
}
|
||||
},
|
||||
aspectRatio: {
|
||||
'16/9': '16 / 9'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
},
|
||||
aspectRatio: {
|
||||
'16/9': '16 / 9',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5178
|
||||
}
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5178,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"name": "presi",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"db:push": "pnpm --filter @presi/backend db:push",
|
||||
"db:studio": "pnpm --filter @presi/backend db:studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
"name": "presi",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"db:push": "pnpm --filter @presi/backend db:push",
|
||||
"db:studio": "pnpm --filter @presi/backend db:studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"name": "@presi/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@presi/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,86 +3,86 @@
|
|||
*/
|
||||
|
||||
export interface Deck {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Slide {
|
||||
id: string;
|
||||
deckId: string;
|
||||
order: number;
|
||||
content: SlideContent;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
deckId: string;
|
||||
order: number;
|
||||
content: SlideContent;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SlideContent {
|
||||
type: 'title' | 'content' | 'image' | 'split';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
bulletPoints?: string[];
|
||||
type: 'title' | 'content' | 'image' | 'split';
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
bulletPoints?: string[];
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
fonts: ThemeFonts;
|
||||
isDefault: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
fonts: ThemeFonts;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
text: string;
|
||||
accent: string;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
background: string;
|
||||
text: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
export interface ThemeFonts {
|
||||
heading: string;
|
||||
body: string;
|
||||
heading: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface SharedDeck {
|
||||
id: string;
|
||||
deckId: string;
|
||||
shareCode: string;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
deckId: string;
|
||||
shareCode: string;
|
||||
expiresAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// DTOs
|
||||
export interface CreateDeckDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeckDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
isPublic?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
themeId?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateSlideDto {
|
||||
content: SlideContent;
|
||||
order?: number;
|
||||
content: SlideContent;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateSlideDto {
|
||||
content?: SlideContent;
|
||||
order?: number;
|
||||
content?: SlideContent;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface ReorderSlidesDto {
|
||||
slides: { id: string; order: number }[];
|
||||
slides: { id: string; order: number }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue