style: auto-format codebase with Prettier

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

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

View file

@ -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/

View file

@ -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!,
},
});

View file

@ -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
}
}

View file

@ -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"
}
}

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -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>;

View file

@ -6,23 +6,23 @@ export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -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),
}));

View file

@ -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],
}),
}));

View file

@ -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],
}),
}));

View file

@ -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),
}));

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -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',
};
}
}

View file

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

View file

@ -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();

View file

@ -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);
}
}

View file

@ -1,7 +1,7 @@
import { IsOptional, IsDateString } from 'class-validator';
export class CreateShareDto {
@IsOptional()
@IsDateString()
expiresAt?: string;
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View file

@ -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 {}

View file

@ -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 };
}
}

View file

@ -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);
}
}

View file

@ -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[];
}

View file

@ -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 {}

View file

@ -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 };
}
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -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"]
}

View file

@ -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"
}
}
}
}

View file

@ -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>
);
}

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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,
},
});

View file

@ -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',
},
});

View file

@ -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',
},
});

View file

@ -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,
},
});

View file

@ -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',
},
});

View file

@ -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%',
},
});

View file

@ -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>;
};

View file

@ -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',
},
});

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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',
},
});

View file

@ -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',
},
});

View file

@ -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',
},
});

View file

@ -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,
},
});

View file

@ -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',
},
});

View file

@ -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,
},
});

View file

@ -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: {},
});

View file

@ -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,
},
});

View file

@ -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,
},
});

View file

@ -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',
},
});

View file

@ -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,
},
});

View file

@ -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

View file

@ -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": {}
}
}

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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;
};

View file

@ -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;
}
};

View file

@ -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;
}
};

View file

@ -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>
);
}
```

View file

@ -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>
);
};

View file

@ -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',
},
});

View file

@ -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;
}

View file

@ -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"]
}

View file

@ -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;
}

View file

@ -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"
}

View file

@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -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');
},
};

View file

@ -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();

View file

@ -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();

View file

@ -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}

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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,
},
});

View file

@ -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"
}

View file

@ -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"
}
}

View file

@ -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 }[];
}

View file

@ -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"]
}