mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(moodlit): restore from git history, migrate to local-first + Hono
- Restore from git history (was deleted in 079b55a79)
- Delete NestJS backend and mobile app
- Create Hono/Bun server with preset moods API
- Create local-first store (moods, sequences) with 8 preset moods
- Rewrite web app: Moods page with color gradient cards and activation,
Sequences page with CRUD, auth via shared-auth-ui with guest mode
- Add CLAUDE.md, dev scripts, root CLAUDE.md entry
- 0 type errors on both server and web
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f2b9f893b
commit
72da55d3d0
139 changed files with 5607 additions and 5877 deletions
|
|
@ -60,6 +60,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
|
||||
| **reader** | Text-to-Speech with offline audio | Mobile |
|
||||
| **bauntown** | Developer community website | Landing |
|
||||
| **moodlit** | Ambient lighting & mood app | Server, Web, Landing |
|
||||
| **calc** | Calculator & converter | Web |
|
||||
| **playground** | LLM playground | Web |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,295 +0,0 @@
|
|||
# Moodlit Project Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
**Moodlit** ist eine Ambient-Lighting-App, die es Benutzern ermöglicht, benutzerdefinierte Lichtstimmungen mit Farbverläufen und Animationen zu erstellen. Die App unterstützt sowohl bildschirmbasierte Beleuchtung als auch Geräte-Taschenlampensteuerung.
|
||||
|
||||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Backend | 3012 | http://localhost:3012 |
|
||||
| Web App | 5182 | http://localhost:5182 |
|
||||
| Landing Page | 4332 | http://localhost:4332 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/moodlit/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@moodlit/backend)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.ts
|
||||
│ │ ├── app.module.ts
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── database.module.ts
|
||||
│ │ │ ├── connection.ts
|
||||
│ │ │ └── schema/
|
||||
│ │ │ ├── moods.schema.ts
|
||||
│ │ │ └── sequences.schema.ts
|
||||
│ │ ├── moods/
|
||||
│ │ │ ├── moods.module.ts
|
||||
│ │ │ ├── moods.controller.ts
|
||||
│ │ │ ├── moods.service.ts
|
||||
│ │ │ └── dto/
|
||||
│ │ ├── sequences/
|
||||
│ │ │ ├── sequences.module.ts
|
||||
│ │ │ ├── sequences.controller.ts
|
||||
│ │ │ ├── sequences.service.ts
|
||||
│ │ │ └── dto/
|
||||
│ │ └── health/
|
||||
│ │
|
||||
│ ├── web/ # SvelteKit web app (@moodlit/web)
|
||||
│ │ └── src/
|
||||
│ │ ├── app.html
|
||||
│ │ ├── app.css
|
||||
│ │ └── routes/
|
||||
│ │ ├── +layout.svelte
|
||||
│ │ └── +page.svelte
|
||||
│ │
|
||||
│ ├── mobile/ # Expo React Native app (@moodlit/mobile)
|
||||
│ │ ├── app/ # Expo Router routes
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── store/
|
||||
│ │ └── utils/
|
||||
│ │
|
||||
│ └── landing/ # Astro landing page (@moodlit/landing)
|
||||
│
|
||||
├── package.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
# Alle Apps starten
|
||||
pnpm moodlit:dev # Run all moodlit apps
|
||||
|
||||
# Einzelne Apps starten
|
||||
pnpm dev:moodlit:backend # Start backend server (port 3012)
|
||||
pnpm dev:moodlit:web # Start web app (port 5182)
|
||||
pnpm dev:moodlit:mobile # Start mobile app
|
||||
pnpm dev:moodlit:landing # Start landing page (port 4332)
|
||||
pnpm dev:moodlit:app # Start web + backend together
|
||||
|
||||
# Datenbank
|
||||
pnpm moodlit:db:push # Push schema to database
|
||||
pnpm moodlit:db:studio # Open Drizzle Studio
|
||||
pnpm moodlit:db:seed # Seed initial data
|
||||
|
||||
# Deploy
|
||||
pnpm deploy:landing:moodlit # Deploy landing to Cloudflare Pages
|
||||
```
|
||||
|
||||
### Backend (apps/moodlit/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
### Web App (apps/moodlit/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Mobile App (apps/moodlit/apps/mobile)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Build and run iOS simulator
|
||||
pnpm android # Build and run Android emulator
|
||||
pnpm build:dev # EAS development build
|
||||
```
|
||||
|
||||
### Landing Page (apps/moodlit/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (port 4332)
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview build
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
|
||||
| **Web** | SvelteKit 2.x, Svelte 5 (runes), Tailwind CSS 4 |
|
||||
| **Mobile** | Expo SDK 54, React Native 0.81, NativeWind, Zustand |
|
||||
| **Landing** | Astro 5.x, Tailwind CSS |
|
||||
| **Auth** | Mana Core Auth (JWT) |
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Mood Library
|
||||
- Vorkonfigurierte Lichtstimmungen (Fire, Breath, Northern Lights, Thunder, etc.)
|
||||
- Verschiedene Farbverläufe und Animationstypen
|
||||
- Standard-Moods für jeden Benutzer
|
||||
|
||||
### 2. Custom Moods
|
||||
- Erstelle eigene Lichtstimmungen
|
||||
- Anpassbare Farben und Animationen
|
||||
- Speichern und Wiederverwenden
|
||||
|
||||
### 3. Sequences
|
||||
- Mehrere Moods zu einer Sequenz verketten
|
||||
- Konfigurierbare Dauer und Übergänge
|
||||
- Automatische Wiedergabe
|
||||
|
||||
### 4. Dual Output
|
||||
- Bildschirmbasierte Beleuchtung
|
||||
- Geräte-Taschenlampensteuerung
|
||||
- Umschalten zwischen Modi
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health
|
||||
```
|
||||
GET /api/v1/health # Health check
|
||||
```
|
||||
|
||||
### Moods
|
||||
```
|
||||
GET /api/v1/moods # List all moods
|
||||
POST /api/v1/moods # Create mood
|
||||
GET /api/v1/moods/:id # Get mood
|
||||
PUT /api/v1/moods/:id # Update mood
|
||||
DELETE /api/v1/moods/:id # Delete mood
|
||||
```
|
||||
|
||||
### Sequences
|
||||
```
|
||||
GET /api/v1/sequences # List all sequences
|
||||
POST /api/v1/sequences # Create sequence
|
||||
GET /api/v1/sequences/:id # Get sequence
|
||||
PUT /api/v1/sequences/:id # Update sequence
|
||||
DELETE /api/v1/sequences/:id # Delete sequence
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### moods
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | TEXT | Owner |
|
||||
| `name` | TEXT | Mood name |
|
||||
| `colors` | JSONB | Array of color hex codes |
|
||||
| `animation` | TEXT | Animation type |
|
||||
| `is_default` | BOOLEAN | Default mood flag |
|
||||
| `created_at` | TIMESTAMP | Created date |
|
||||
| `updated_at` | TIMESTAMP | Updated date |
|
||||
|
||||
### sequences
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | TEXT | Owner |
|
||||
| `name` | TEXT | Sequence name |
|
||||
| `mood_ids` | JSONB | Array of mood IDs |
|
||||
| `duration` | INTEGER | Duration per mood (seconds) |
|
||||
| `created_at` | TIMESTAMP | Created date |
|
||||
| `updated_at` | TIMESTAMP | Updated date |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=3012
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/moods
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5182,http://localhost:8081
|
||||
DEV_BYPASS_AUTH=true
|
||||
DEV_USER_ID=your-test-user-id
|
||||
```
|
||||
|
||||
### Web (.env)
|
||||
```env
|
||||
PUBLIC_BACKEND_URL=http://localhost:3012
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
### Mobile (.env)
|
||||
```env
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3012
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Datenbank erstellen
|
||||
|
||||
```bash
|
||||
# PostgreSQL Container muss laufen
|
||||
docker compose -f docker-compose.dev.yml up -d postgres
|
||||
|
||||
# Datenbank erstellen
|
||||
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE moods;"
|
||||
|
||||
# Schema pushen
|
||||
pnpm moodlit:db:push
|
||||
```
|
||||
|
||||
### 2. Apps starten
|
||||
|
||||
```bash
|
||||
# Backend + Web zusammen
|
||||
pnpm dev:moodlit:app
|
||||
|
||||
# Oder einzeln:
|
||||
pnpm dev:moodlit:backend # Terminal 1
|
||||
pnpm dev:moodlit:web # Terminal 2
|
||||
pnpm dev:moodlit:mobile # Terminal 3
|
||||
pnpm dev:moodlit:landing # Terminal 4 (optional)
|
||||
```
|
||||
|
||||
### 3. URLs öffnen
|
||||
|
||||
- Web App: http://localhost:5182
|
||||
- Landing: http://localhost:4332
|
||||
- API Health: http://localhost:3012/api/v1/health
|
||||
|
||||
## Testing API (mit curl)
|
||||
|
||||
```bash
|
||||
# Health Check
|
||||
curl http://localhost:3012/api/v1/health
|
||||
|
||||
# Login (get token)
|
||||
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
|
||||
|
||||
# Moods abrufen
|
||||
curl http://localhost:3012/api/v1/moods \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Neues Mood erstellen
|
||||
curl -X POST http://localhost:3012/api/v1/moods \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Sunset", "colors": ["#ff6b6b", "#feca57", "#ff9ff3"], "animation": "gradient"}'
|
||||
|
||||
# Sequence erstellen
|
||||
curl -X POST http://localhost:3012/api/v1/sequences \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Evening Flow", "moodIds": ["mood-id-1", "mood-id-2"], "duration": 30}'
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header)
|
||||
2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432)
|
||||
3. **Port**: Backend läuft auf Port 3012, Web auf 5182, Landing auf 4332
|
||||
4. **Mobile**: Verwendet Expo Dev Client (nicht Expo Go) wegen nativer Dependencies
|
||||
5. **Theme**: Purple/Violet als Primärfarbe für die Mood-Thematik
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/moods',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
{
|
||||
"name": "@moodlit/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { MoodsModule } from './moods/moods.module';
|
||||
import { SequencesModule } from './sequences/sequences.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
MoodsModule,
|
||||
SequencesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
import type { Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './moods.schema';
|
||||
export * from './sequences.schema';
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { pgTable, uuid, text, jsonb, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const moods = pgTable('moods', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
colors: jsonb('colors').notNull().$type<string[]>(),
|
||||
animation: text('animation'),
|
||||
isDefault: boolean('is_default').default(false),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
export type Mood = typeof moods.$inferSelect;
|
||||
export type NewMood = typeof moods.$inferInsert;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { pgTable, uuid, text, jsonb, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const sequences = pgTable('sequences', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
moodIds: jsonb('mood_ids').notNull().$type<string[]>(),
|
||||
duration: integer('duration').default(30),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
export type Sequence = typeof sequences.$inferSelect;
|
||||
export type NewSequence = typeof sequences.$inferInsert;
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'moods-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5182',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3012;
|
||||
await app.listen(port);
|
||||
console.log(`Moods backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { IsString, IsArray, IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateMoodDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
colors: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
animation?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateMoodDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
colors?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
animation?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { MoodsService } from './moods.service';
|
||||
import { CreateMoodDto, UpdateMoodDto } from './dto';
|
||||
|
||||
@Controller('moods')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MoodsController {
|
||||
constructor(private readonly moodsService: MoodsService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.moodsService.findAllByUser(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.moodsService.findOne(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateMoodDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.moodsService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMoodDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.moodsService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.moodsService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MoodsController } from './moods.controller';
|
||||
import { MoodsService } from './moods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MoodsController],
|
||||
providers: [MoodsService],
|
||||
exports: [MoodsService],
|
||||
})
|
||||
export class MoodsModule {}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { moods, type Mood, type NewMood } from '../db/schema/moods.schema';
|
||||
import { CreateMoodDto, UpdateMoodDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class MoodsService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Mood[]> {
|
||||
return this.db.select().from(moods).where(eq(moods.userId, userId));
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<Mood> {
|
||||
const [mood] = await this.db
|
||||
.select()
|
||||
.from(moods)
|
||||
.where(and(eq(moods.id, id), eq(moods.userId, userId)));
|
||||
|
||||
if (!mood) {
|
||||
throw new NotFoundException(`Mood with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return mood;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateMoodDto): Promise<Mood> {
|
||||
const newMood: NewMood = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
colors: dto.colors,
|
||||
animation: dto.animation,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
};
|
||||
|
||||
const [mood] = await this.db.insert(moods).values(newMood).returning();
|
||||
return mood;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateMoodDto): Promise<Mood> {
|
||||
// Verify the mood exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(moods)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(moods.id, id), eq(moods.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// Verify the mood exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
await this.db.delete(moods).where(and(eq(moods.id, id), eq(moods.userId, userId)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { IsString, IsArray, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateSequenceDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
moodIds: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class UpdateSequenceDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
moodIds?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SequencesService } from './sequences.service';
|
||||
import { CreateSequenceDto, UpdateSequenceDto } from './dto';
|
||||
|
||||
@Controller('sequences')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SequencesController {
|
||||
constructor(private readonly sequencesService: SequencesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.sequencesService.findAllByUser(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.sequencesService.findOne(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateSequenceDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.sequencesService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSequenceDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.sequencesService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.sequencesService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SequencesController } from './sequences.controller';
|
||||
import { SequencesService } from './sequences.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SequencesController],
|
||||
providers: [SequencesService],
|
||||
exports: [SequencesService],
|
||||
})
|
||||
export class SequencesModule {}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { sequences, type Sequence, type NewSequence } from '../db/schema/sequences.schema';
|
||||
import { CreateSequenceDto, UpdateSequenceDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class SequencesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Sequence[]> {
|
||||
return this.db.select().from(sequences).where(eq(sequences.userId, userId));
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<Sequence> {
|
||||
const [sequence] = await this.db
|
||||
.select()
|
||||
.from(sequences)
|
||||
.where(and(eq(sequences.id, id), eq(sequences.userId, userId)));
|
||||
|
||||
if (!sequence) {
|
||||
throw new NotFoundException(`Sequence with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateSequenceDto): Promise<Sequence> {
|
||||
const newSequence: NewSequence = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
moodIds: dto.moodIds,
|
||||
duration: dto.duration ?? 30,
|
||||
};
|
||||
|
||||
const [sequence] = await this.db.insert(sequences).values(newSequence).returning();
|
||||
return sequence;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateSequenceDto): Promise<Sequence> {
|
||||
// Verify the sequence exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(sequences)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(sequences.id, id), eq(sequences.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// Verify the sequence exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
await this.db.delete(sequences).where(and(eq(sequences.id, id), eq(sequences.userId, userId)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"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": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
25
apps-archived/moodlit/apps/mobile/.gitignore
vendored
25
apps-archived/moodlit/apps/mobile/.gitignore
vendored
|
|
@ -1,25 +0,0 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Moodlit** is a React Native mobile application built with Expo Router, targeting iOS and Android platforms. The app creates ambient lighting effects using the device's screen and flashlight with customizable color gradients and animations. It uses NativeWind (TailwindCSS for React Native) for styling and Zustand for state management.
|
||||
|
||||
### Key Features
|
||||
- **Mood Library**: Pre-configured lighting moods (Fire, Breath, Northern Lights, Thunder, etc.) with different color gradients and animation types
|
||||
- **Custom Moods**: Create custom lighting effects with personalized colors and animations
|
||||
- **Sequences**: Chain multiple moods together with configurable durations and transitions
|
||||
- **Dual Output**: Toggle between screen-based lighting and device flashlight
|
||||
- **Settings**: Adjustable animation speed, haptic feedback, brightness, and auto-timer functionality
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Starting the Development Server
|
||||
```bash
|
||||
npm start # Start Expo dev server with dev client
|
||||
npm run ios # Build and run on iOS simulator
|
||||
npm run android # Build and run on Android emulator
|
||||
npm run web # Run web version
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
npm run prebuild # Generate native directories for iOS/Android
|
||||
npm run build:dev # Build development build via EAS
|
||||
npm run build:preview # Build preview version via EAS
|
||||
npm run build:prod # Build production version via EAS
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
npm run lint # Run ESLint and Prettier check
|
||||
npm run format # Auto-fix with ESLint and format with Prettier
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Routing
|
||||
- **Expo Router** (file-based routing): Routes are defined by file structure in the `app/` directory
|
||||
- `app/_layout.tsx`: Root layout component that wraps all screens
|
||||
- `app/index.tsx`: Home screen
|
||||
- `app/details.tsx`: Details screen
|
||||
- Route navigation uses `expo-router` Link component with typed routes enabled
|
||||
|
||||
### State Management
|
||||
- **Zustand**: Global state management in `store/store.ts`
|
||||
- Store definitions follow a pattern of state + action methods
|
||||
- Example store structure includes state interface and create function
|
||||
|
||||
### Backend Integration
|
||||
- **Supabase Client**: Configured in `utils/supabase.ts`
|
||||
- Uses AsyncStorage for session persistence
|
||||
- Environment variables required:
|
||||
- `EXPO_PUBLIC_SUPABASE_URL`
|
||||
- `EXPO_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Auto-refresh tokens and persistent sessions enabled
|
||||
|
||||
### Styling System
|
||||
- **NativeWind**: TailwindCSS for React Native
|
||||
- Global styles imported via `global.css` in root layout
|
||||
- Tailwind config includes `app/**` and `components/**` content paths
|
||||
- Styles defined as string literals with `className` prop (not `style`)
|
||||
- Example: `className="flex flex-1 bg-white"`
|
||||
|
||||
### Path Aliases
|
||||
- TypeScript configured with `@/*` path alias mapping to root directory
|
||||
- Import components/utils with `@/components/...` or `@/utils/...`
|
||||
|
||||
### Components Structure
|
||||
- Reusable components in `components/` directory:
|
||||
- `Button.tsx`: Touchable button component
|
||||
- `Container.tsx`: Layout wrapper
|
||||
- `ScreenContent.tsx`: Screen template with title and separator
|
||||
- `EditScreenInfo.tsx`: Info display component
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- `app.json`: Expo configuration with typed routes and tsconfigPaths experiments enabled
|
||||
- `tsconfig.json`: TypeScript with strict mode and path aliases
|
||||
- `tailwind.config.js`: NativeWind preset with custom content paths
|
||||
- `babel.config.js`: Babel configuration for Expo
|
||||
- `metro.config.js`: Metro bundler configuration
|
||||
- `.env`: Environment variables (not committed, contains Supabase credentials)
|
||||
|
||||
## Development Notes
|
||||
|
||||
- This project uses React 19.1.0 and React Native 0.81.5
|
||||
- Expo SDK version 54
|
||||
- TypeScript strict mode is enabled
|
||||
- The app requires Expo Dev Client (not Expo Go) due to custom native dependencies
|
||||
- Web support is available via Metro bundler with static output
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Moodlit",
|
||||
"slug": "moods",
|
||||
"version": "1.0.0",
|
||||
"scheme": "moods",
|
||||
"platforms": ["ios", "android"],
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Erlaubt $(PRODUCT_NAME) die Kamera für die Taschenlampen-Funktion zu nutzen."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#000000",
|
||||
"image": "./assets/splash.png",
|
||||
"imageWidth": 200
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "default",
|
||||
"icon": "./assets/mood-light-logo.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"requireFullScreen": false,
|
||||
"bundleIdentifier": "com.tilljs.moodlight",
|
||||
"icon": "./assets/mood-light.icon",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIRequiresFullScreen": false,
|
||||
"UIUserInterfaceStyle": "Dark",
|
||||
"UISupportedInterfaceOrientations": [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationLandscapeLeft",
|
||||
"UIInterfaceOrientationLandscapeRight"
|
||||
],
|
||||
"UISupportedInterfaceOrientations~ipad": [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationPortraitUpsideDown",
|
||||
"UIInterfaceOrientationLandscapeLeft",
|
||||
"UIInterfaceOrientationLandscapeRight"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/mood-light-logo.png",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"package": "com.tilljs.moodlight",
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"FLASHLIGHT",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "faec0f17-97e2-4be5-9a85-d281b5635e7a"
|
||||
}
|
||||
},
|
||||
"owner": "memoro"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { Container } from '@/components/Container';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<Container>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `flex flex-1 bg-black`,
|
||||
title: `text-xl font-bold text-white`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-blue-500`,
|
||||
};
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import '../global.css';
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { AnimationType } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
['#FF6B6B', '#FFE66D'],
|
||||
['#4ECDC4', '#44A08D'],
|
||||
['#6B8DD6', '#8E37D7'],
|
||||
['#F857A6', '#FF5858'],
|
||||
['#2E3192', '#1BFFFF'],
|
||||
['#FFD89B', '#19547B'],
|
||||
['#00F260', '#0575E6'],
|
||||
['#FA8BFF', '#2BD2FF'],
|
||||
['#FEB692', '#EA5455'],
|
||||
['#A8EDEA', '#FED6E3'],
|
||||
];
|
||||
|
||||
const ANIMATION_TYPES: { label: string; value: AnimationType }[] = [
|
||||
{ label: 'Gradient', value: 'gradient' },
|
||||
{ label: 'Pulsieren', value: 'pulse' },
|
||||
{ label: 'Wellen', value: 'wave' },
|
||||
];
|
||||
|
||||
export default function CreateMood() {
|
||||
const router = useRouter();
|
||||
const addCustomMood = useStore((state) => state.addCustomMood);
|
||||
const settings = useStore((state) => state.settings);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [colors, setColors] = useState<string[]>(PRESET_COLORS[0]);
|
||||
const [animationType, setAnimationType] = useState<AnimationType>('gradient');
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
handleHaptic();
|
||||
addCustomMood({
|
||||
name: name.trim(),
|
||||
colors,
|
||||
animationType,
|
||||
});
|
||||
|
||||
Alert.alert('Erfolg', 'Mood wurde erstellt!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Mood erstellen',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center pb-8 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-4">
|
||||
{/* Preview */}
|
||||
<View className="mb-6 overflow-hidden rounded-3xl" style={styles.previewCard}>
|
||||
<LinearGradient
|
||||
colors={colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.previewGradient}
|
||||
>
|
||||
<View className="absolute left-5 right-5 top-5">
|
||||
<Text className="text-3xl font-bold text-white">{name || 'Vorschau'}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* Name Input */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. Meditation"
|
||||
className={`p-3 text-base ${theme.input} rounded-lg ${theme.text}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
maxLength={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Color Selection */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Farben</Text>
|
||||
<View className="flex-row flex-wrap gap-3">
|
||||
{PRESET_COLORS.map((colorPair, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setColors(colorPair);
|
||||
}}
|
||||
style={[styles.colorBox, colors === colorPair && styles.selectedColorBox]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={colorPair}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Animation Type */}
|
||||
<View className={`${theme.cardBg} mb-6 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Animation</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{ANIMATION_TYPES.map((type) => (
|
||||
<Pressable
|
||||
key={type.value}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setAnimationType(type.value);
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
animationType === type.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
animationType === type.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Button */}
|
||||
<Pressable onPress={handleCreate} className="items-center rounded-2xl bg-blue-500 p-4">
|
||||
<Text className="text-lg font-semibold text-white">Mood erstellen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
previewCard: {
|
||||
aspectRatio: 16 / 9,
|
||||
width: '100%',
|
||||
},
|
||||
previewGradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
colorBox: {
|
||||
width: '30%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
selectedColorBox: {
|
||||
borderWidth: 4,
|
||||
borderColor: '#3B82F6',
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, View, Text, TextInput, Pressable, Alert, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { MoodSequenceItem } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ label: '2s', value: 2 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
];
|
||||
|
||||
export default function CreateSequence() {
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const addSequence = useStore((state) => state.addSequence);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [transitionDuration, setTransitionDuration] = useState(5);
|
||||
const [items, setItems] = useState<MoodSequenceItem[]>([]);
|
||||
const [showMoodPicker, setShowMoodPicker] = useState(false);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const addMoodToSequence = (moodId: string) => {
|
||||
handleHaptic();
|
||||
setItems([...items, { moodId, duration: 300 }]); // 5 Minuten default
|
||||
setShowMoodPicker(false);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMoodFromSequence = (index: number) => {
|
||||
handleHaptic();
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateMoodDuration = (index: number, duration: number) => {
|
||||
handleHaptic();
|
||||
const newItems = [...items];
|
||||
newItems[index].duration = duration;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
Alert.alert('Fehler', 'Bitte füge mindestens einen Mood hinzu');
|
||||
return;
|
||||
}
|
||||
|
||||
handleHaptic();
|
||||
addSequence({
|
||||
name: name.trim(),
|
||||
items,
|
||||
transitionDuration,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
Alert.alert('Erfolg', 'Sequenz wurde erstellt!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const getMoodById = (id: string) => moods.find((m) => m.id === id);
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Neue Sequenz',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 32, paddingHorizontal: 16 }}
|
||||
>
|
||||
{/* Name Input */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. Morgen Routine"
|
||||
className={`p-3 text-base ${theme.input} rounded-lg ${theme.text}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
maxLength={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Transition Duration */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Übergangsdauer</Text>
|
||||
<View className="flex-row gap-2">
|
||||
{TRANSITION_OPTIONS.map((option) => (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setTransitionDuration(option.value);
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
transitionDuration === option.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
transitionDuration === option.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Moods in Sequenz */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Moods ({items.length})</Text>
|
||||
{items.map((item, index) => {
|
||||
const mood = getMoodById(item.moodId);
|
||||
if (!mood) return null;
|
||||
|
||||
return (
|
||||
<View key={index} className="mb-2 rounded-xl bg-gray-800 p-4">
|
||||
{/* Mood Name & Delete */}
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="flex-1 text-base font-medium text-white">
|
||||
{index + 1}. {mood.name}
|
||||
</Text>
|
||||
<Pressable onPress={() => removeMoodFromSequence(index)} className="ml-2">
|
||||
<Icon name="close" size={18} color="#EF4444" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Duration Slider */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm text-white">
|
||||
Dauer: {formatDuration(item.duration)}
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={3600}
|
||||
step={1}
|
||||
value={item.duration}
|
||||
onValueChange={(value) => updateMoodDuration(index, Math.round(value))}
|
||||
minimumTrackTintColor="#3B82F6"
|
||||
maximumTrackTintColor="#4B5563"
|
||||
thumbTintColor="#3B82F6"
|
||||
/>
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">1 Sek</Text>
|
||||
<Text className="text-xs text-gray-500">60 Min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Mood Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(true);
|
||||
}}
|
||||
className="mt-2 items-center rounded-xl bg-gray-700 p-3"
|
||||
>
|
||||
<Text className="font-medium text-white">+ Mood hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Create Button */}
|
||||
<Pressable onPress={handleCreate} className="items-center rounded-2xl bg-blue-500 p-4">
|
||||
<Text className="text-lg font-semibold text-white">Sequenz speichern</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
|
||||
{/* Mood Picker Modal */}
|
||||
<Modal
|
||||
visible={showMoodPicker}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setShowMoodPicker(false)}
|
||||
>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="max-h-[70%] rounded-t-3xl bg-gray-900 p-4">
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-white">Mood auswählen</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={24} color="#fff" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{moods.map((mood) => (
|
||||
<Pressable
|
||||
key={mood.id}
|
||||
onPress={() => addMoodToSequence(mood.id)}
|
||||
className="mb-2 rounded-xl bg-gray-800 p-4"
|
||||
>
|
||||
<Text className="text-lg font-medium text-white">{mood.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { View } from 'react-native';
|
||||
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
|
||||
import { Container } from '@/components/Container';
|
||||
import { ScreenContent } from '@/components/ScreenContent';
|
||||
|
||||
export default function Details() {
|
||||
const { name } = useLocalSearchParams();
|
||||
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Stack.Screen options={{ title: 'Details' }} />
|
||||
<Container>
|
||||
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 bg-black',
|
||||
};
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollView, View, Text, TextInput, Pressable, Alert, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { MoodSequenceItem } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ label: '2s', value: 2 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
];
|
||||
|
||||
export default function EditSequence() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSequence = useStore((state) => state.updateSequence);
|
||||
|
||||
const sequence = sequences.find((s) => s.id === id);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [transitionDuration, setTransitionDuration] = useState(5);
|
||||
const [items, setItems] = useState<MoodSequenceItem[]>([]);
|
||||
const [showMoodPicker, setShowMoodPicker] = useState(false);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
// Lade bestehende Sequenz-Daten
|
||||
useEffect(() => {
|
||||
if (sequence) {
|
||||
setName(sequence.name);
|
||||
setTransitionDuration(sequence.transitionDuration);
|
||||
setItems([...sequence.items]);
|
||||
}
|
||||
}, [sequence]);
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const addMoodToSequence = (moodId: string) => {
|
||||
handleHaptic();
|
||||
setItems([...items, { moodId, duration: 300 }]); // 5 Minuten default
|
||||
setShowMoodPicker(false);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMoodFromSequence = (index: number) => {
|
||||
handleHaptic();
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateMoodDuration = (index: number, duration: number) => {
|
||||
handleHaptic();
|
||||
const newItems = [...items];
|
||||
newItems[index].duration = duration;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
Alert.alert('Fehler', 'Bitte füge mindestens einen Mood hinzu');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
|
||||
handleHaptic();
|
||||
updateSequence(id, {
|
||||
name: name.trim(),
|
||||
items,
|
||||
transitionDuration,
|
||||
});
|
||||
|
||||
Alert.alert('Erfolg', 'Sequenz wurde aktualisiert!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const getMoodById = (moodId: string) => moods.find((m) => m.id === moodId);
|
||||
|
||||
if (!sequence) {
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg} items-center justify-center`}>
|
||||
<Text className={`${theme.text} text-xl`}>Sequenz nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Sequenz bearbeiten',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center pb-8 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-6">
|
||||
{/* Name Input */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. Morgen Routine"
|
||||
className={`p-3 text-base ${theme.input} rounded-lg ${theme.text}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
maxLength={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Transition Duration */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Übergangsdauer</Text>
|
||||
<View className="flex-row gap-2">
|
||||
{TRANSITION_OPTIONS.map((option) => (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setTransitionDuration(option.value);
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
transitionDuration === option.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
transitionDuration === option.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Moods in Sequenz */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>
|
||||
Moods ({items.length})
|
||||
</Text>
|
||||
{items.map((item, index) => {
|
||||
const mood = getMoodById(item.moodId);
|
||||
if (!mood) return null;
|
||||
|
||||
return (
|
||||
<View key={index} className="mb-2 rounded-xl bg-gray-800 p-4">
|
||||
{/* Mood Name & Delete */}
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="flex-1 text-base font-medium text-white">
|
||||
{index + 1}. {mood.name}
|
||||
</Text>
|
||||
<Pressable onPress={() => removeMoodFromSequence(index)} className="ml-2">
|
||||
<Icon name="close" size={18} color="#EF4444" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Duration Slider */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm text-white">
|
||||
Dauer: {formatDuration(item.duration)}
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={3600}
|
||||
step={1}
|
||||
value={item.duration}
|
||||
onValueChange={(value) => updateMoodDuration(index, Math.round(value))}
|
||||
minimumTrackTintColor="#3B82F6"
|
||||
maximumTrackTintColor="#4B5563"
|
||||
thumbTintColor="#3B82F6"
|
||||
/>
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">1 Sek</Text>
|
||||
<Text className="text-xs text-gray-500">60 Min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Mood Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(true);
|
||||
}}
|
||||
className="mt-2 items-center rounded-xl bg-gray-700 p-3"
|
||||
>
|
||||
<Text className="font-medium text-white">+ Mood hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<Pressable onPress={handleSave} className="mx-2 items-center rounded-2xl bg-blue-500 p-4">
|
||||
<Text className="text-lg font-semibold text-white">Änderungen speichern</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Mood Picker Modal */}
|
||||
<Modal
|
||||
visible={showMoodPicker}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setShowMoodPicker(false)}
|
||||
>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="max-h-[70%] rounded-t-3xl bg-gray-900 p-4">
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-white">Mood auswählen</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={24} color="#fff" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{moods.map((mood) => (
|
||||
<Pressable
|
||||
key={mood.id}
|
||||
onPress={() => addMoodToSequence(mood.id)}
|
||||
className="mb-2 rounded-xl bg-gray-800 p-4"
|
||||
>
|
||||
<Text className="text-lg font-medium text-white">{mood.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Text, Pressable, View, StyleSheet } from 'react-native';
|
||||
import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatlist';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { SequenceCard } from '@/components/SequenceCard';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { Mood } from '@/store/store';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const reorderMoods = useStore((state) => state.reorderMoods);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScreen = () => {
|
||||
handleHaptic();
|
||||
updateSettings({ screenEnabled: !settings.screenEnabled });
|
||||
};
|
||||
|
||||
const toggleFlashlight = () => {
|
||||
handleHaptic();
|
||||
updateSettings({ flashlightEnabled: !settings.flashlightEnabled });
|
||||
};
|
||||
|
||||
const handleMoodPress = (id: string) => {
|
||||
handleHaptic();
|
||||
router.push(`/mood/${id}`);
|
||||
};
|
||||
|
||||
const handleSequencePress = (id: string) => {
|
||||
handleHaptic();
|
||||
router.push(`/sequence/${id}`);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4">
|
||||
<Text className={`text-3xl font-bold ${theme.text}`}>Moods</Text>
|
||||
<View className="flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/create-mood');
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Icon name="plus-circle" size={28} color="#fff" weight="regular" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/sequences');
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Icon name="square-stack" size={28} color="#fff" weight="regular" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Icon name="settings" size={28} color="#fff" weight="regular" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sequenzen Liste */}
|
||||
{sequences.length > 0 && (
|
||||
<>
|
||||
{sequences.map((sequence) => (
|
||||
<SequenceCard
|
||||
key={sequence.id}
|
||||
sequence={sequence}
|
||||
moods={moods}
|
||||
onPress={() => handleSequencePress(sequence.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView className={`flex flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<View className="flex-1 items-center">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }}>
|
||||
<DraggableFlatList
|
||||
data={moods}
|
||||
onDragEnd={({ data }) => reorderMoods(data)}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 100, paddingTop: 64 }}
|
||||
ListHeaderComponent={renderHeader}
|
||||
renderItem={({ item, drag, isActive }) => (
|
||||
<ScaleDecorator>
|
||||
<MoodCard
|
||||
mood={item}
|
||||
onPress={() => handleMoodPress(item.id)}
|
||||
onLongPress={drag}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</ScaleDecorator>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Gradient am unteren Rand */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.2)', 'rgba(0,0,0,0.5)', 'rgba(0,0,0,0.7)']}
|
||||
locations={[0, 0.4, 0.7, 1]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 250,
|
||||
}}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Toggle Buttons am unteren Rand */}
|
||||
<View className="absolute bottom-0 left-0 right-0 items-center pb-8 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-6">
|
||||
<View className="flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={toggleScreen}
|
||||
className={`flex-1 flex-row items-center justify-center gap-2 rounded-2xl py-4 ${
|
||||
settings.screenEnabled
|
||||
? 'border-2 border-gray-200 bg-white'
|
||||
: 'border-2 border-gray-600 bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
name="phone-portrait"
|
||||
size={20}
|
||||
color={settings.screenEnabled ? '#000' : '#fff'}
|
||||
weight={settings.screenEnabled ? 'fill' : 'regular'}
|
||||
/>
|
||||
<Text
|
||||
className={`font-semibold ${settings.screenEnabled ? 'text-gray-900' : 'text-white'}`}
|
||||
>
|
||||
Bildschirm
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={toggleFlashlight}
|
||||
className={`flex-1 flex-row items-center justify-center gap-2 rounded-2xl py-4 ${
|
||||
settings.flashlightEnabled
|
||||
? 'border-2 border-gray-200 bg-white'
|
||||
: 'border-2 border-gray-600 bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
name="flashlight"
|
||||
size={20}
|
||||
color={settings.flashlightEnabled ? '#000' : '#fff'}
|
||||
weight={settings.flashlightEnabled ? 'fill' : 'regular'}
|
||||
/>
|
||||
<Text
|
||||
className={`font-semibold ${settings.flashlightEnabled ? 'text-gray-900' : 'text-white'}`}
|
||||
>
|
||||
Taschenlampe
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({});
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import * as Brightness from 'expo-brightness';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { AnimatedMoodBackground } from '@/components/AnimatedMoodBackground';
|
||||
import { useStore } from '@/store/store';
|
||||
import { useFlashlight } from '@/hooks/useFlashlight';
|
||||
|
||||
export default function MoodDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
|
||||
const [remainingTime, setRemainingTime] = useState<number | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSwitchRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Verhindert, dass der Bildschirm ausgeht
|
||||
useKeepAwake();
|
||||
|
||||
const mood = moods.find((m) => m.id === id);
|
||||
|
||||
// Flashlight-Hook mit Helligkeitssteuerung
|
||||
useFlashlight({
|
||||
enabled: settings.flashlightEnabled && !!mood,
|
||||
animationType: mood?.animationType || 'gradient',
|
||||
animationSpeed: settings.animationSpeed,
|
||||
brightness: settings.flashlightBrightness,
|
||||
});
|
||||
|
||||
// Helligkeit setzen
|
||||
useEffect(() => {
|
||||
const setBrightness = async () => {
|
||||
try {
|
||||
const { status } = await Brightness.requestPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
await Brightness.setBrightnessAsync(settings.brightness);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Brightness error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setBrightness();
|
||||
|
||||
// Helligkeit beim Verlassen zurücksetzen
|
||||
return () => {
|
||||
Brightness.setBrightnessAsync(0.5).catch(() => {});
|
||||
};
|
||||
}, [settings.brightness]);
|
||||
|
||||
// Auto-Timer
|
||||
useEffect(() => {
|
||||
if (settings.autoTimer > 0) {
|
||||
setRemainingTime(settings.autoTimer * 60); // In Sekunden
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingTime((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
router.back();
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [settings.autoTimer]);
|
||||
|
||||
// Automatischer Mood-Wechsel
|
||||
useEffect(() => {
|
||||
if (settings.autoMoodSwitch && mood) {
|
||||
const interval = settings.autoMoodSwitchInterval * 60 * 1000; // In Millisekunden
|
||||
|
||||
autoSwitchRef.current = setTimeout(() => {
|
||||
const currentIndex = moods.findIndex((m) => m.id === mood.id);
|
||||
const nextIndex = (currentIndex + 1) % moods.length;
|
||||
const nextMood = moods[nextIndex];
|
||||
|
||||
router.replace(`/mood/${nextMood.id}`);
|
||||
}, interval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoSwitchRef.current) {
|
||||
clearTimeout(autoSwitchRef.current);
|
||||
}
|
||||
};
|
||||
}, [settings.autoMoodSwitch, settings.autoMoodSwitchInterval, mood, moods]);
|
||||
|
||||
if (!mood) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-900">
|
||||
<Text className="text-xl text-white">Mood nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable className="flex-1" onPress={() => router.back()}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* Bildschirm-Animation (nur wenn aktiviert) */}
|
||||
{settings.screenEnabled ? (
|
||||
<AnimatedMoodBackground mood={mood} animationSpeed={settings.animationSpeed} />
|
||||
) : (
|
||||
<View className="absolute inset-0 bg-black" />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, Alert } from 'react-native';
|
||||
import DraggableFlatList, {
|
||||
ScaleDecorator,
|
||||
RenderItemParams,
|
||||
} from 'react-native-draggable-flatlist';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { Mood } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
export default function ReorderMoods() {
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const reorderMoods = useStore((state) => state.reorderMoods);
|
||||
const removeMood = useStore((state) => state.removeMood);
|
||||
const settings = useStore((state) => state.settings);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (mood: Mood) => {
|
||||
if (!mood.isCustom) {
|
||||
Alert.alert('Hinweis', 'Standard-Moods können nicht gelöscht werden');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert('Mood löschen', `"${mood.name}" wirklich löschen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
handleHaptic();
|
||||
removeMood(mood.id);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item, drag, isActive }: RenderItemParams<Mood>) => {
|
||||
return (
|
||||
<ScaleDecorator>
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth, alignSelf: 'center' }}>
|
||||
<Pressable
|
||||
onLongPress={() => {
|
||||
handleHaptic();
|
||||
drag();
|
||||
}}
|
||||
disabled={isActive}
|
||||
className={`mx-2 mb-4 ${isActive ? 'opacity-80' : ''}`}
|
||||
>
|
||||
<View className="h-32 flex-row overflow-hidden rounded-3xl">
|
||||
<LinearGradient
|
||||
colors={item.colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
className="flex-1 flex-row items-center justify-between px-6"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xl font-bold text-white">{item.name}</Text>
|
||||
{item.isCustom && (
|
||||
<Text className="mt-1 text-xs text-white/80">Benutzerdefiniert</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="rounded-full bg-white/20 px-3 py-2">
|
||||
<Text className="text-sm text-white">☰</Text>
|
||||
</View>
|
||||
{item.isCustom && (
|
||||
<Pressable
|
||||
onPress={() => handleDelete(item)}
|
||||
className="rounded-full bg-red-500/80 px-3 py-2"
|
||||
>
|
||||
<Text className="text-sm text-white">🗑️</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScaleDecorator>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView className={`flex-1 ${theme.bg}`}>
|
||||
<SafeAreaView className="flex-1" edges={['top']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Moods sortieren',
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<View className={`p-4 ${theme.cardBg} border-b ${theme.border}`}>
|
||||
<Text className={`${theme.textSecondary} text-center`}>
|
||||
Halte einen Mood gedrückt zum Verschieben
|
||||
</Text>
|
||||
</View>
|
||||
<DraggableFlatList
|
||||
data={moods}
|
||||
onDragEnd={({ data }) => {
|
||||
handleHaptic();
|
||||
reorderMoods(data);
|
||||
}}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 32 }}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { AnimatedMoodBackground } from '@/components/AnimatedMoodBackground';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useFlashlight } from '@/hooks/useFlashlight';
|
||||
import { useStore } from '@/store/store';
|
||||
|
||||
export default function SequencePlayer() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState<number>(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [localBrightness, setLocalBrightness] = useState(settings.brightness);
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const transitionOpacity = useSharedValue(1);
|
||||
|
||||
useKeepAwake();
|
||||
|
||||
const sequence = sequences.find((s) => s.id === id);
|
||||
const currentItem = sequence?.items[currentIndex];
|
||||
const currentMood = currentItem ? moods.find((m) => m.id === currentItem.moodId) : null;
|
||||
const nextItem = sequence?.items[currentIndex + 1];
|
||||
const nextMood = nextItem ? moods.find((m) => m.id === nextItem.moodId) : null;
|
||||
|
||||
// Flashlight Hook mit Helligkeitssteuerung
|
||||
useFlashlight({
|
||||
enabled: settings.flashlightEnabled && !!currentMood && !isTransitioning,
|
||||
animationType: currentMood?.animationType || 'gradient',
|
||||
animationSpeed: settings.animationSpeed,
|
||||
brightness: settings.flashlightBrightness,
|
||||
});
|
||||
|
||||
// Helligkeit setzen
|
||||
useEffect(() => {
|
||||
const setBrightness = async () => {
|
||||
try {
|
||||
const { status } = await Brightness.requestPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
await Brightness.setBrightnessAsync(settings.brightness);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Brightness error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setBrightness();
|
||||
|
||||
return () => {
|
||||
Brightness.setBrightnessAsync(0.5).catch(() => {});
|
||||
};
|
||||
}, [settings.brightness]);
|
||||
|
||||
// Sequenz Timer
|
||||
useEffect(() => {
|
||||
if (!sequence || !currentItem) return;
|
||||
|
||||
setRemainingTime(currentItem.duration); // duration ist bereits in Sekunden
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingTime((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Zeit abgelaufen, zum nächsten Mood oder Ende
|
||||
if (currentIndex < sequence.items.length - 1) {
|
||||
startTransition();
|
||||
} else {
|
||||
// Sequenz beendet
|
||||
router.back();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [currentIndex, sequence]);
|
||||
|
||||
const startTransition = () => {
|
||||
if (!sequence) return;
|
||||
|
||||
setIsTransitioning(true);
|
||||
transitionOpacity.value = 1;
|
||||
|
||||
// Fade out
|
||||
transitionOpacity.value = withTiming(
|
||||
0,
|
||||
{
|
||||
duration: sequence.transitionDuration * 1000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
},
|
||||
() => {
|
||||
// Nach dem Fade, wechsle zum nächsten Mood
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setIsTransitioning(false);
|
||||
transitionOpacity.value = 1;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const animatedTransitionStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: transitionOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const handleBrightnessChange = async (value: number) => {
|
||||
setLocalBrightness(value);
|
||||
try {
|
||||
await Brightness.setBrightnessAsync(value);
|
||||
updateSettings({ brightness: value });
|
||||
} catch (error) {
|
||||
console.log('Brightness change error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!sequence || !currentMood) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-900">
|
||||
<Text className="text-xl text-white">Sequenz nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* Current Mood Background */}
|
||||
{settings.screenEnabled ? (
|
||||
<>
|
||||
{/* Next Mood (darunter, für Crossfade) */}
|
||||
{isTransitioning && nextMood && (
|
||||
<View className="absolute inset-0">
|
||||
<AnimatedMoodBackground mood={nextMood} animationSpeed={settings.animationSpeed} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Current Mood (darüber, wird ausgeblendet) */}
|
||||
<Animated.View
|
||||
className="absolute inset-0"
|
||||
style={isTransitioning ? animatedTransitionStyle : undefined}
|
||||
>
|
||||
<AnimatedMoodBackground mood={currentMood} animationSpeed={settings.animationSpeed} />
|
||||
</Animated.View>
|
||||
</>
|
||||
) : (
|
||||
<View className="absolute inset-0 bg-black" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<View className="absolute left-4 right-4 top-12 flex-row items-center justify-between">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className="flex-row items-center gap-3 rounded-full bg-black/30 px-4 py-2 opacity-60"
|
||||
>
|
||||
<Icon name="close" size={16} color="#FFFFFF" weight="bold" />
|
||||
<Text className="text-xl font-bold text-white">{sequence.name}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Center Progress */}
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<View className="items-center rounded-2xl bg-black/40 px-6 py-4 opacity-60">
|
||||
<Text className="mb-2 text-2xl font-bold text-white">{currentMood.name}</Text>
|
||||
<View className="mb-3 flex-row gap-1">
|
||||
{sequence.items.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
index === currentIndex ? 'bg-white' : 'bg-white/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text className="mb-1 text-lg text-white">{formatTime(remainingTime)}</Text>
|
||||
<Text className="text-sm text-white/70">
|
||||
Mood {currentIndex + 1} von {sequence.items.length}
|
||||
</Text>
|
||||
{nextMood && (
|
||||
<Text className="mt-2 text-xs text-white/70">Nächster: {nextMood.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Brightness Slider */}
|
||||
<View className="absolute bottom-12 left-4 right-4 flex-row items-center gap-3 rounded-2xl bg-black/30 px-4 py-3 opacity-60">
|
||||
<Icon name="sun" size={24} color="#FFFFFF" />
|
||||
<View className="flex-1">
|
||||
<Slider
|
||||
minimumValue={0.1}
|
||||
maximumValue={1}
|
||||
step={0.05}
|
||||
value={localBrightness}
|
||||
onValueChange={handleBrightnessChange}
|
||||
minimumTrackTintColor="#FFFFFF"
|
||||
maximumTrackTintColor="rgba(255, 255, 255, 0.3)"
|
||||
thumbTintColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { ScrollView, View, Text, Pressable, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
export default function Sequences() {
|
||||
const router = useRouter();
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const removeSequence = useStore((state) => state.removeSequence);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
Alert.alert('Sequenz löschen', `Möchtest du "${name}" wirklich löschen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
handleHaptic();
|
||||
removeSequence(id);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const getTotalDuration = (sequence: (typeof sequences)[0]) => {
|
||||
const totalSeconds = sequence.items.reduce((sum, item) => sum + item.duration, 0);
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Sequenzen',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center pb-4 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-6">
|
||||
{/* Sequenzen Liste */}
|
||||
{sequences.length > 0 ? (
|
||||
sequences.map((sequence) => (
|
||||
<View key={sequence.id} className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push(`/sequence/${sequence.id}`);
|
||||
}}
|
||||
>
|
||||
<Text className={`text-xl font-bold ${theme.text} mb-2`}>{sequence.name}</Text>
|
||||
<Text className={`${theme.textSecondary} text-sm`}>
|
||||
{sequence.items.length} Moods · {getTotalDuration(sequence)} · Übergang{' '}
|
||||
{sequence.transitionDuration}s
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View className="mt-3 flex-row gap-2">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push(`/edit-sequence/${sequence.id}`);
|
||||
}}
|
||||
className="flex-1 flex-row items-center justify-center gap-2 rounded-xl bg-blue-500 py-3"
|
||||
>
|
||||
<Icon name="pencil" size={16} color="#fff" weight="bold" />
|
||||
<Text className="text-sm font-semibold text-white">Bearbeiten</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleDelete(sequence.id, sequence.name)}
|
||||
className="flex-1 flex-row items-center justify-center gap-2 rounded-xl bg-red-500 py-3"
|
||||
>
|
||||
<Icon name="trash" size={16} color="#fff" weight="bold" />
|
||||
<Text className="text-sm font-semibold text-white">Löschen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="items-center px-2 py-8">
|
||||
<Text className={`${theme.textSecondary} mb-4 text-center`}>
|
||||
Noch keine Sequenzen erstellt
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Neue Sequenz Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/create-sequence');
|
||||
}}
|
||||
className="mx-2 mt-2 items-center rounded-2xl bg-blue-500 p-4"
|
||||
>
|
||||
<Text className="text-lg font-semibold text-white">+ Neue Sequenz</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { ScrollView, View, Text, Switch, Pressable, Linking } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
|
||||
export default function Settings() {
|
||||
const router = useRouter();
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingChange = (key: string, value: any) => {
|
||||
handleHaptic();
|
||||
updateSettings({ [key]: value });
|
||||
};
|
||||
|
||||
const timerOptions = [
|
||||
{ label: 'Aus', value: 0 },
|
||||
{ label: '5 Min', value: 5 },
|
||||
{ label: '10 Min', value: 10 },
|
||||
{ label: '15 Min', value: 15 },
|
||||
{ label: '30 Min', value: 30 },
|
||||
{ label: '60 Min', value: 60 },
|
||||
];
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Einstellungen',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center">
|
||||
<View
|
||||
style={{ width: '100%', maxWidth: responsive.maxContentWidth }}
|
||||
className="px-6 pb-4 pt-4"
|
||||
>
|
||||
{/* Animationsgeschwindigkeit */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>
|
||||
Animationsgeschwindigkeit
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} mb-3`}>
|
||||
{settings.animationSpeed === 0.5
|
||||
? 'Langsam'
|
||||
: settings.animationSpeed === 1
|
||||
? 'Normal'
|
||||
: 'Schnell'}
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={0.5}
|
||||
maximumValue={2}
|
||||
step={0.5}
|
||||
value={settings.animationSpeed}
|
||||
onValueChange={(value) => handleSettingChange('animationSpeed', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Haptisches Feedback */}
|
||||
<View
|
||||
className={`${theme.cardBg} mx-2 mb-4 flex-row items-center justify-between rounded-2xl p-4`}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className={`text-lg font-semibold ${theme.text}`}>Haptisches Feedback</Text>
|
||||
<Text className={`${theme.textSecondary} text-sm`}>Vibration beim Tippen</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.hapticFeedback}
|
||||
onValueChange={(value) => handleSettingChange('hapticFeedback', value)}
|
||||
trackColor={{ false: '#E5E7EB', true: '#6B8DD6' }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Helligkeit */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>
|
||||
Bildschirm-Helligkeit
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} mb-3`}>
|
||||
{Math.round(settings.brightness * 100)}%
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={0.1}
|
||||
maximumValue={1}
|
||||
step={0.1}
|
||||
value={settings.brightness}
|
||||
onValueChange={(value) => handleSettingChange('brightness', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Taschenlampen-Helligkeit */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>
|
||||
Taschenlampen-Helligkeit
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} mb-3`}>
|
||||
Stufe {settings.flashlightBrightness} von 10
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={10}
|
||||
step={1}
|
||||
value={settings.flashlightBrightness}
|
||||
onValueChange={(value) => handleSettingChange('flashlightBrightness', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Auto-Timer */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Auto-Timer</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{timerOptions.map((option) => (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => handleSettingChange('autoTimer', option.value)}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
settings.autoTimer === option.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
settings.autoTimer === option.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Auto Mood Switch */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-lg font-semibold ${theme.text}`}>
|
||||
Automatischer Mood-Wechsel
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} text-sm`}>Wechselt zwischen Moods</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.autoMoodSwitch}
|
||||
onValueChange={(value) => handleSettingChange('autoMoodSwitch', value)}
|
||||
trackColor={{ false: '#E5E7EB', true: '#6B8DD6' }}
|
||||
/>
|
||||
</View>
|
||||
{settings.autoMoodSwitch && (
|
||||
<>
|
||||
<Text className={`${theme.textSecondary} mb-2`}>
|
||||
Intervall: {settings.autoMoodSwitchInterval} Min
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={30}
|
||||
step={1}
|
||||
value={settings.autoMoodSwitchInterval}
|
||||
onValueChange={(value) => handleSettingChange('autoMoodSwitchInterval', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Credits */}
|
||||
<View className="mx-2 mb-4 mt-8 items-center">
|
||||
<View className={`${theme.cardBg} w-full items-center rounded-2xl p-5 shadow-sm`}>
|
||||
<Icon name="heart-fill" size={18} color="#EF4444" weight="fill" />
|
||||
<View className="mt-2 flex-row items-center">
|
||||
<Text className={`${theme.text} text-center text-sm font-medium`}>
|
||||
Made by Till Schneider for the{' '}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
Linking.openURL('https://manacore.ai');
|
||||
}}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-blue-500 underline">Manacore</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text className={`${theme.textSecondary} mt-1 text-xs`}>Free Forever</Text>
|
||||
<Text className={`${theme.textSecondary} mt-1 text-xs`}>Version 1.0</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 430 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 430 KiB |
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"fill": "system-dark",
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"glass": true,
|
||||
"hidden": false,
|
||||
"image-name": "moods-logo.png",
|
||||
"name": "moods-logo",
|
||||
"position": {
|
||||
"scale": 0.92,
|
||||
"translation-in-points": [0, 0]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": ["watchOS"],
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB |
|
|
@ -1,12 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
plugins.push('react-native-worklets/plugin');
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// This is an optional configuration file used primarily for debugging purposes when reporting issues.
|
||||
// It is safe to delete this file as it does not affect the functionality of your application.
|
||||
{
|
||||
"cesVersion": "2.20.1",
|
||||
"projectName": "moods",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "stack"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "zustand",
|
||||
"type": "state-management"
|
||||
},
|
||||
{
|
||||
"name": "supabase",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.8.2"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "25.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,532 +0,0 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import type { Mood } from '@/store/store';
|
||||
|
||||
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
||||
|
||||
interface AnimatedMoodBackgroundProps {
|
||||
mood: Mood;
|
||||
animationSpeed?: number; // 0.5 = langsam, 1 = normal, 2 = schnell
|
||||
}
|
||||
|
||||
export const AnimatedMoodBackground = ({
|
||||
mood,
|
||||
animationSpeed = 1,
|
||||
}: AnimatedMoodBackgroundProps) => {
|
||||
const opacity = useSharedValue(1);
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
// Für sunrise/sunset brauchen wir einen dedizierten Wert
|
||||
const needsGradientSequence = mood.animationType === 'sunrise' || mood.animationType === 'sunset';
|
||||
const colorIndex = useSharedValue(needsGradientSequence ? 0 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
// Basis-Dauer angepasst an Geschwindigkeit
|
||||
const baseDuration = 2000 / animationSpeed;
|
||||
|
||||
if (mood.animationType === 'pulse') {
|
||||
// Pulsieren: Opacity und Scale ändern
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.6, { duration: baseDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: baseDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.1, { duration: baseDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: baseDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else if (mood.animationType === 'wave') {
|
||||
// Wellen: Nur Opacity
|
||||
const waveDuration = 3000 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.7, { duration: waveDuration, easing: Easing.sin }),
|
||||
withTiming(1, { duration: waveDuration, easing: Easing.sin })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else if (mood.animationType === 'flash') {
|
||||
// Flash: Schnelles Aufblitzen von schwarz zu weiß
|
||||
const flashDuration = 100 / animationSpeed; // Sehr kurz für Blitz-Effekt
|
||||
const pauseDuration = 500 / animationSpeed; // Pause zwischen Blitzen
|
||||
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }), // Schwarz
|
||||
withTiming(0, { duration: pauseDuration }), // Pause
|
||||
withTiming(1, { duration: flashDuration, easing: Easing.linear }), // Blitz
|
||||
withTiming(0, { duration: flashDuration, easing: Easing.linear }), // Zurück zu Schwarz
|
||||
withTiming(0, { duration: pauseDuration }) // Pause
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'sos') {
|
||||
// SOS: Morse-Code Pattern (· · · — — — · · ·)
|
||||
const shortFlash = 200 / animationSpeed; // Kurzes Signal
|
||||
const longFlash = 600 / animationSpeed; // Langes Signal
|
||||
const shortPause = 200 / animationSpeed; // Pause zwischen Signalen
|
||||
const letterPause = 600 / animationSpeed; // Pause zwischen Buchstaben
|
||||
const wordPause = 2000 / animationSpeed; // Pause nach SOS
|
||||
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
// S (drei kurze)
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: letterPause, easing: Easing.linear }),
|
||||
|
||||
// O (drei lange)
|
||||
withTiming(1, { duration: longFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: longFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: longFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: letterPause, easing: Easing.linear }),
|
||||
|
||||
// S (drei kurze)
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: wordPause, easing: Easing.linear }) // Lange Pause
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'candle') {
|
||||
// Kerze: Sanftes, langsames Flackern wie echte Kerzenflamme
|
||||
const flickerDuration = 400 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.92, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.88, { duration: flickerDuration * 0.8, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.95, { duration: flickerDuration * 1.2, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.99, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.985, { duration: flickerDuration * 0.8, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.995, { duration: flickerDuration * 1.2, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'police') {
|
||||
// Polizei: Abwechselnd Blau und Rot
|
||||
const blinkDuration = 300 / animationSpeed;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(0, { duration: blinkDuration }),
|
||||
withTiming(1, { duration: 0 }),
|
||||
withTiming(1, { duration: blinkDuration })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'warning') {
|
||||
// Warnsignal: Blinkendes Orange/Gelb
|
||||
const warnDuration = 500 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: warnDuration, easing: Easing.linear }),
|
||||
withTiming(0.3, { duration: warnDuration, easing: Easing.linear })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'disco') {
|
||||
// Disco: Schnell wechselnde Farben
|
||||
const discoSpeed = 400 / animationSpeed;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(0, { duration: discoSpeed }),
|
||||
withTiming(1, { duration: 0 }),
|
||||
withTiming(1, { duration: discoSpeed }),
|
||||
withTiming(2, { duration: 0 }),
|
||||
withTiming(2, { duration: discoSpeed }),
|
||||
withTiming(3, { duration: 0 }),
|
||||
withTiming(3, { duration: discoSpeed }),
|
||||
withTiming(4, { duration: 0 }),
|
||||
withTiming(4, { duration: discoSpeed }),
|
||||
withTiming(5, { duration: 0 }),
|
||||
withTiming(5, { duration: discoSpeed })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'thunder') {
|
||||
// Gewitter: Zufällige Blitze
|
||||
const thunderPattern = () => {
|
||||
return withSequence(
|
||||
withTiming(1, { duration: 0 }), // Grau/Normal
|
||||
withTiming(1, { duration: 2000 / animationSpeed }), // Pause
|
||||
withTiming(3, { duration: 50 / animationSpeed }), // Kurzer Blitz
|
||||
withTiming(1, { duration: 100 / animationSpeed }),
|
||||
withTiming(3, { duration: 80 / animationSpeed }), // Zweiter Blitz
|
||||
withTiming(1, { duration: 3000 / animationSpeed }) // Längere Pause
|
||||
);
|
||||
};
|
||||
opacity.value = withRepeat(thunderPattern(), -1, false);
|
||||
} else if (mood.animationType === 'breath') {
|
||||
// Atem: 4 Sekunden einatmen, 4 Sekunden ausatmen
|
||||
const breathInDuration = 4000 / animationSpeed;
|
||||
const breathOutDuration = 4000 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.3, { duration: breathOutDuration, easing: Easing.inOut(Easing.ease) }), // Ausatmen
|
||||
withTiming(1, { duration: breathInDuration, easing: Easing.inOut(Easing.ease) }) // Einatmen
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.95, { duration: breathOutDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: breathInDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else if (mood.animationType === 'rave') {
|
||||
// Rave: Sehr schnelle, chaotische Farbwechsel
|
||||
const raveSpeed = 150 / animationSpeed;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(0, { duration: raveSpeed }),
|
||||
withTiming(1, { duration: 0 }),
|
||||
withTiming(1, { duration: raveSpeed }),
|
||||
withTiming(2, { duration: 0 }),
|
||||
withTiming(2, { duration: raveSpeed }),
|
||||
withTiming(3, { duration: 0 }),
|
||||
withTiming(3, { duration: raveSpeed }),
|
||||
withTiming(4, { duration: 0 }),
|
||||
withTiming(4, { duration: raveSpeed }),
|
||||
withTiming(5, { duration: 0 }),
|
||||
withTiming(5, { duration: raveSpeed }),
|
||||
withTiming(6, { duration: 0 }),
|
||||
withTiming(6, { duration: raveSpeed }),
|
||||
withTiming(7, { duration: 0 }),
|
||||
withTiming(7, { duration: raveSpeed })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'scanner') {
|
||||
// Scanner: Lichtwelle die hin und her wandert
|
||||
const scanDuration = 2000 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(1, { duration: scanDuration / 4, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: scanDuration / 4 }),
|
||||
withTiming(0, { duration: scanDuration / 4, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0, { duration: scanDuration / 4 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'matrix') {
|
||||
// Matrix: Grün blinkend wie digitaler Code
|
||||
const matrixSpeed = 100 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: matrixSpeed }),
|
||||
withTiming(0.7, { duration: matrixSpeed }),
|
||||
withTiming(1, { duration: matrixSpeed }),
|
||||
withTiming(0.85, { duration: matrixSpeed }),
|
||||
withTiming(1, { duration: matrixSpeed }),
|
||||
withTiming(0.6, { duration: matrixSpeed }),
|
||||
withTiming(1, { duration: matrixSpeed * 2 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'sunrise') {
|
||||
// Sonnenaufgang: Sehr sanfter, langsamer Durchlauf durch verschiedene Gradient-Phasen
|
||||
const phaseDuration = 20000 / animationSpeed; // 20 Sekunden pro Phase
|
||||
const transitionDuration = 8000 / animationSpeed; // 8 Sekunden Übergang
|
||||
|
||||
colorIndex.value = 0; // Start bei 0
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(1.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(2.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(2.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(3.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(3.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(4.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(4.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(5.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(5.5, { duration: phaseDuration - transitionDuration * 2 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'sunset') {
|
||||
// Sonnenuntergang: Sehr sanfter, langsamer Durchlauf durch verschiedene Gradient-Phasen
|
||||
const phaseDuration = 20000 / animationSpeed; // 20 Sekunden pro Phase
|
||||
const transitionDuration = 8000 / animationSpeed; // 8 Sekunden Übergang
|
||||
|
||||
colorIndex.value = 0;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(1.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(2.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(2.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(3.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(3.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(4.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(4.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(5.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(5.5, { duration: phaseDuration - transitionDuration * 2 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}
|
||||
}, [mood.animationType, animationSpeed]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
transform: [{ scale: scale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
// Farb-Logik für verschiedene Animationen
|
||||
const animatedColors = useAnimatedStyle(() => {
|
||||
// Für Polizei: Wechsel zwischen Blau und Rot
|
||||
if (mood.animationType === 'police') {
|
||||
const idx = Math.floor(colorIndex.value);
|
||||
const color = idx === 0 ? '#0000FF' : '#FF0000';
|
||||
return { backgroundColor: color };
|
||||
}
|
||||
|
||||
// Für Disco: Durchlaufe alle Farben
|
||||
if (mood.animationType === 'disco') {
|
||||
const idx = Math.floor(colorIndex.value);
|
||||
const colors = mood.colors;
|
||||
const color = colors[idx % colors.length] || colors[0];
|
||||
return { backgroundColor: color };
|
||||
}
|
||||
|
||||
// Für Rave: Durchlaufe alle Farben (schneller als Disco)
|
||||
if (mood.animationType === 'rave') {
|
||||
const idx = Math.floor(colorIndex.value);
|
||||
const colors = mood.colors;
|
||||
const color = colors[idx % colors.length] || colors[0];
|
||||
return { backgroundColor: color };
|
||||
}
|
||||
|
||||
// Für Gewitter: Normal grau, aber bei opacity > 2 wird es weiß (Blitz)
|
||||
if (mood.animationType === 'thunder') {
|
||||
const isFlash = opacity.value > 2;
|
||||
return { backgroundColor: isFlash ? '#FFFFFF' : '#34495E' };
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
// Für Sonnenaufgang/Sonnenuntergang: Sanfte Übergänge zwischen Phasen
|
||||
const phase0Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
// Fade out when approaching phase 1
|
||||
if (idx < 0.5) return { opacity: 1 };
|
||||
if (idx < 1.5) return { opacity: Math.max(0, 1.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase1Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 0.5) return { opacity: 0 };
|
||||
if (idx < 1.5) return { opacity: Math.min(1, idx - 0.5) };
|
||||
if (idx < 2.5) return { opacity: Math.max(0, 2.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase2Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 1.5) return { opacity: 0 };
|
||||
if (idx < 2.5) return { opacity: Math.min(1, idx - 1.5) };
|
||||
if (idx < 3.5) return { opacity: Math.max(0, 3.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase3Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 2.5) return { opacity: 0 };
|
||||
if (idx < 3.5) return { opacity: Math.min(1, idx - 2.5) };
|
||||
if (idx < 4.5) return { opacity: Math.max(0, 4.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase4Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 3.5) return { opacity: 0 };
|
||||
if (idx < 4.5) return { opacity: Math.min(1, idx - 3.5) };
|
||||
if (idx < 5.5) return { opacity: Math.max(0, 5.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase5Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 4.5) return { opacity: 0 };
|
||||
if (idx < 5.5) return { opacity: Math.min(1, idx - 4.5) };
|
||||
return { opacity: 1 }; // Stay visible at the end before looping
|
||||
});
|
||||
|
||||
const getColors = () => {
|
||||
if (mood.animationType === 'sos') {
|
||||
return ['#FF0000', '#FF0000']; // Einheitliches Rot
|
||||
}
|
||||
if (mood.animationType === 'flash') {
|
||||
return ['#FFFFFF', '#FFFFFF']; // Einheitliches Weiß
|
||||
}
|
||||
if (mood.animationType === 'scanner') {
|
||||
return ['#FF0000', '#FF0000']; // Einheitliches Rot für Scanner
|
||||
}
|
||||
if (mood.animationType === 'matrix') {
|
||||
return ['#00FF00', '#00FF00']; // Einheitliches Grün für Matrix
|
||||
}
|
||||
if (
|
||||
mood.animationType === 'police' ||
|
||||
mood.animationType === 'disco' ||
|
||||
mood.animationType === 'rave' ||
|
||||
mood.animationType === 'thunder' ||
|
||||
mood.animationType === 'sunrise' ||
|
||||
mood.animationType === 'sunset'
|
||||
) {
|
||||
// Für diese Modi verwenden wir animatedColors statt Gradient
|
||||
return ['transparent', 'transparent'];
|
||||
}
|
||||
return mood.colors;
|
||||
};
|
||||
|
||||
// Für SOS, Flash, Scanner und Matrix brauchen wir einen schwarzen Hintergrund
|
||||
const needsBlackBackground =
|
||||
mood.animationType === 'sos' ||
|
||||
mood.animationType === 'flash' ||
|
||||
mood.animationType === 'scanner' ||
|
||||
mood.animationType === 'matrix';
|
||||
const needsAnimatedBackground =
|
||||
mood.animationType === 'police' ||
|
||||
mood.animationType === 'disco' ||
|
||||
mood.animationType === 'rave' ||
|
||||
mood.animationType === 'thunder';
|
||||
|
||||
// Für Sonnenaufgang/Sonnenuntergang: Gradient-Paare extrahieren
|
||||
const getGradientPhases = () => {
|
||||
if (!needsGradientSequence) return [];
|
||||
const phases = [];
|
||||
const colors = mood.colors;
|
||||
for (let i = 0; i < colors.length; i += 2) {
|
||||
phases.push([colors[i], colors[i + 1] || colors[i]]);
|
||||
}
|
||||
return phases;
|
||||
};
|
||||
|
||||
const gradientPhases = getGradientPhases();
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsBlackBackground && (
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#000000' }]} />
|
||||
)}
|
||||
|
||||
{needsAnimatedBackground ? (
|
||||
<Animated.View style={[StyleSheet.absoluteFill, animatedStyle, animatedColors]} />
|
||||
) : needsGradientSequence ? (
|
||||
<>
|
||||
{/* Render all gradient phases, only one visible at a time */}
|
||||
{gradientPhases.length > 0 && (
|
||||
<>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[0]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase0Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[1]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase1Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[2]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase2Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[3]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase3Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[4]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase4Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[5]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase5Opacity]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<AnimatedLinearGradient
|
||||
colors={getColors()}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, animatedStyle]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, Pressable, PressableProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
} & PressableProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(({ title, ...pressableProps }, ref) => {
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
{...pressableProps}
|
||||
className={`${styles.button} ${pressableProps.className}`}
|
||||
>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text className="text-white">{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1 bg-gray-800`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center text-gray-400`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import React from 'react';
|
||||
|
||||
type IconName =
|
||||
| 'settings'
|
||||
| 'settings-sliders'
|
||||
| 'close'
|
||||
| 'sun'
|
||||
| 'star'
|
||||
| 'star-fill'
|
||||
| 'arrow-clockwise'
|
||||
| 'chevron-left'
|
||||
| 'phone-portrait'
|
||||
| 'flashlight'
|
||||
| 'play-circle'
|
||||
| 'plus-circle'
|
||||
| 'square-stack'
|
||||
| 'list-bullet'
|
||||
| 'pencil'
|
||||
| 'trash';
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
weight?: SymbolWeight;
|
||||
}
|
||||
|
||||
const iconMap: Record<IconName, string> = {
|
||||
settings: 'gearshape',
|
||||
'settings-sliders': 'slider.horizontal.3',
|
||||
close: 'xmark',
|
||||
sun: 'sun.max.fill',
|
||||
star: 'star',
|
||||
'star-fill': 'star.fill',
|
||||
'arrow-clockwise': 'arrow.clockwise',
|
||||
'chevron-left': 'chevron.left',
|
||||
'phone-portrait': 'iphone',
|
||||
flashlight: 'flashlight.on.fill',
|
||||
'play-circle': 'play.circle',
|
||||
'plus-circle': 'plus.circle.fill',
|
||||
'square-stack': 'square.stack.fill',
|
||||
'list-bullet': 'list.bullet',
|
||||
pencil: 'pencil',
|
||||
trash: 'trash',
|
||||
};
|
||||
|
||||
export const Icon = ({ name, size = 24, color = '#FFFFFF', weight = 'regular' }: IconProps) => {
|
||||
return (
|
||||
<SymbolView
|
||||
name={iconMap[name]}
|
||||
size={size}
|
||||
type="hierarchical"
|
||||
tintColor={color}
|
||||
weight={weight}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { Text, Pressable, View, StyleSheet } from 'react-native';
|
||||
|
||||
import type { Mood } from '@/store/store';
|
||||
|
||||
interface MoodCardProps {
|
||||
mood: Mood;
|
||||
onPress: () => void;
|
||||
onFavoritePress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const MoodCard = ({
|
||||
mood,
|
||||
onPress,
|
||||
onFavoritePress,
|
||||
onLongPress,
|
||||
isActive,
|
||||
}: MoodCardProps) => {
|
||||
// Check if mood has light colors (for text color adjustment)
|
||||
const isLightMood = mood.name === 'Licht';
|
||||
const textColor = isLightMood ? 'text-gray-900' : 'text-white';
|
||||
const badgeBg = isLightMood ? 'bg-gray-900/20' : 'bg-white/20';
|
||||
const badgeText = isLightMood ? 'text-gray-900/90' : 'text-white/90';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
className="mx-2 mb-6"
|
||||
disabled={isActive}
|
||||
>
|
||||
<View
|
||||
className="overflow-hidden rounded-3xl shadow-lg"
|
||||
style={[styles.card, isActive && styles.activeCard]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={mood.colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{/* Mood Info */}
|
||||
<View className="absolute bottom-4 left-5 right-5">
|
||||
<Text className={`${textColor} mb-1 text-3xl font-bold tracking-tight`}>
|
||||
{mood.name}
|
||||
</Text>
|
||||
{mood.isCustom && (
|
||||
<View className={`${badgeBg} mt-1 self-start rounded-full px-3 py-1`}>
|
||||
<Text className={`${badgeText} text-xs font-medium`}>Benutzerdefiniert</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
aspectRatio: 16 / 9,
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
activeCard: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center bg-black`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-800`,
|
||||
title: `text-xl font-bold text-white`,
|
||||
};
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { Text, Pressable, View, StyleSheet } from 'react-native';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
import type { Mood, MoodSequence } from '@/store/store';
|
||||
|
||||
interface SequenceCardProps {
|
||||
sequence: MoodSequence;
|
||||
moods: Mood[];
|
||||
onPress: () => void;
|
||||
onLongPress?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const SequenceCard = ({
|
||||
sequence,
|
||||
moods,
|
||||
onPress,
|
||||
onLongPress,
|
||||
isActive,
|
||||
}: SequenceCardProps) => {
|
||||
// Hole die Farben der ersten 3 Moods in der Sequenz
|
||||
const getGradientColors = () => {
|
||||
const colors: string[] = [];
|
||||
sequence.items.slice(0, 3).forEach((item) => {
|
||||
const mood = moods.find((m) => m.id === item.moodId);
|
||||
if (mood && mood.colors.length > 0) {
|
||||
colors.push(mood.colors[0]);
|
||||
}
|
||||
});
|
||||
// Falls weniger als 2 Farben, fülle mit Schwarz auf
|
||||
while (colors.length < 2) {
|
||||
colors.push('#000000');
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
const getTotalDuration = () => {
|
||||
const totalSeconds = sequence.items.reduce((sum, item) => sum + item.duration, 0);
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
className="mx-2 mb-6"
|
||||
disabled={isActive}
|
||||
>
|
||||
<View
|
||||
className="overflow-hidden rounded-3xl shadow-lg"
|
||||
style={[styles.card, isActive && styles.activeCard]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={getGradientColors()}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{/* Play Icon Overlay */}
|
||||
<View className="absolute right-4 top-4 rounded-full bg-black/30 p-2">
|
||||
<Icon name="play-circle" size={24} color="#fff" weight="fill" />
|
||||
</View>
|
||||
|
||||
{/* Sequence Info */}
|
||||
<View className="absolute bottom-4 left-5 right-5">
|
||||
<Text className="mb-1 text-3xl font-bold tracking-tight text-white">
|
||||
{sequence.name}
|
||||
</Text>
|
||||
<View className="mt-1 self-start rounded-full bg-white/20 px-3 py-1">
|
||||
<Text className="text-xs font-medium text-white/90">
|
||||
{sequence.items.length} Moods · {getTotalDuration()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
aspectRatio: 16 / 9,
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
activeCard: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.23.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/* eslint-env node */
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useTorch } from 'react-native-torch-nitro';
|
||||
|
||||
import type { AnimationType } from '@/store/store';
|
||||
|
||||
interface UseFlashlightProps {
|
||||
enabled: boolean;
|
||||
animationType: AnimationType;
|
||||
animationSpeed?: number;
|
||||
brightness?: number; // 1-10 (iOS), wird zu 0-maxLevel gemappt
|
||||
}
|
||||
|
||||
export const useFlashlight = ({
|
||||
enabled,
|
||||
animationType,
|
||||
animationSpeed = 1,
|
||||
brightness = 10,
|
||||
}: UseFlashlightProps) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const sosTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { on, off, setLevel, getMaxLevel } = useTorch({
|
||||
onError: (error) => {
|
||||
console.log('Torch error:', error.code);
|
||||
},
|
||||
});
|
||||
|
||||
const maxLevel = getMaxLevel() || 10;
|
||||
const targetLevel = Math.round((brightness / 10) * maxLevel);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (sosTimeoutRef.current) {
|
||||
clearTimeout(sosTimeoutRef.current);
|
||||
sosTimeoutRef.current = null;
|
||||
}
|
||||
off();
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationType === 'sos') {
|
||||
// SOS: Morse-Code Pattern (··· --- ···)
|
||||
const shortDuration = 200 / animationSpeed;
|
||||
const longDuration = 600 / animationSpeed;
|
||||
const charPause = 200 / animationSpeed;
|
||||
const wordPause = 1400 / animationSpeed;
|
||||
|
||||
const playSOSPattern = () => {
|
||||
let currentStep = 0;
|
||||
const steps = [
|
||||
// S - 3 kurze
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause + charPause },
|
||||
// O - 3 lange
|
||||
{ isOn: true, duration: longDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: longDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: longDuration },
|
||||
{ isOn: false, duration: charPause + charPause },
|
||||
// S - 3 kurze
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: wordPause },
|
||||
];
|
||||
|
||||
const runStep = () => {
|
||||
if (!enabled || currentStep >= steps.length) {
|
||||
currentStep = 0;
|
||||
}
|
||||
|
||||
const step = steps[currentStep];
|
||||
if (step.isOn) {
|
||||
setLevel(targetLevel);
|
||||
} else {
|
||||
off();
|
||||
}
|
||||
currentStep++;
|
||||
|
||||
sosTimeoutRef.current = setTimeout(runStep, step.duration);
|
||||
};
|
||||
|
||||
runStep();
|
||||
};
|
||||
|
||||
playSOSPattern();
|
||||
} else if (animationType === 'warning') {
|
||||
// Warnsignal: Blinkendes Pattern
|
||||
const warnDuration = 500 / animationSpeed;
|
||||
let isOn = false;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
isOn = !isOn;
|
||||
if (isOn) {
|
||||
setLevel(targetLevel);
|
||||
} else {
|
||||
off();
|
||||
}
|
||||
}, warnDuration);
|
||||
} else if (animationType === 'thunder') {
|
||||
// Gewitter: Zufällige Blitze
|
||||
const runThunderPattern = () => {
|
||||
off(); // Meistens aus
|
||||
|
||||
// Zufälliger Blitz nach 2-4 Sekunden
|
||||
const waitTime = (2000 + Math.random() * 2000) / animationSpeed;
|
||||
|
||||
sosTimeoutRef.current = setTimeout(() => {
|
||||
// Kurzer Blitz
|
||||
setLevel(maxLevel); // Volle Helligkeit für Blitz
|
||||
setTimeout(() => {
|
||||
off();
|
||||
// Manchmal zweiter Blitz
|
||||
if (Math.random() > 0.5) {
|
||||
setTimeout(() => {
|
||||
setLevel(maxLevel);
|
||||
setTimeout(() => {
|
||||
off();
|
||||
runThunderPattern();
|
||||
}, 80 / animationSpeed);
|
||||
}, 100 / animationSpeed);
|
||||
} else {
|
||||
runThunderPattern();
|
||||
}
|
||||
}, 50 / animationSpeed);
|
||||
}, waitTime);
|
||||
};
|
||||
|
||||
runThunderPattern();
|
||||
} else if (animationType === 'pulse') {
|
||||
// Pulsieren zwischen niedrig und hoch
|
||||
let increasing = true;
|
||||
let currentBrightness = 1;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (increasing) {
|
||||
currentBrightness += 1;
|
||||
if (currentBrightness >= targetLevel) {
|
||||
increasing = false;
|
||||
}
|
||||
} else {
|
||||
currentBrightness -= 1;
|
||||
if (currentBrightness <= 1) {
|
||||
increasing = true;
|
||||
}
|
||||
}
|
||||
setLevel(currentBrightness);
|
||||
}, 100 / animationSpeed);
|
||||
} else {
|
||||
// Alle anderen Moods: Taschenlampe konstant an
|
||||
setLevel(targetLevel);
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}, [enabled, animationType, animationSpeed, targetLevel, maxLevel]);
|
||||
|
||||
return {
|
||||
maxLevel,
|
||||
currentBrightness: brightness,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
export type ScreenSize = 'small' | 'medium' | 'large' | 'xlarge';
|
||||
|
||||
export const useResponsive = () => {
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
// Breakpoints
|
||||
const isSmall = width < 768; // Phone
|
||||
const isMedium = width >= 768 && width < 1024; // Tablet Portrait
|
||||
const isLarge = width >= 1024 && width < 1440; // Tablet Landscape / Small Desktop
|
||||
const isXLarge = width >= 1440; // Large Desktop / Mac
|
||||
|
||||
const screenSize: ScreenSize = isSmall
|
||||
? 'small'
|
||||
: isMedium
|
||||
? 'medium'
|
||||
: isLarge
|
||||
? 'large'
|
||||
: 'xlarge';
|
||||
|
||||
// Responsive values
|
||||
const maxContentWidth = isSmall ? width : isMedium ? 720 : isLarge ? 960 : 1200;
|
||||
const numColumns = isSmall ? 1 : isMedium ? 2 : isLarge ? 2 : 3;
|
||||
const horizontalPadding = isSmall ? 16 : isMedium ? 32 : 48;
|
||||
const cardAspectRatio = isSmall ? 16 / 9 : 2 / 1;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
isSmall,
|
||||
isMedium,
|
||||
isLarge,
|
||||
isXLarge,
|
||||
isTablet: isMedium || isLarge,
|
||||
isDesktop: isLarge || isXLarge,
|
||||
screenSize,
|
||||
maxContentWidth,
|
||||
numColumns,
|
||||
horizontalPadding,
|
||||
cardAspectRatio,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
{
|
||||
"name": "@moodlit/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "^5.1.0",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"expo": "^54.0.0",
|
||||
"expo-av": "^16.0.7",
|
||||
"expo-brightness": "^14.0.7",
|
||||
"expo-camera": "^17.0.9",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-dev-client": "~6.0.13",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linear-gradient": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.10",
|
||||
"expo-splash-screen": "^0.21.1",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "^1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"nativewind": "latest",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-nitro-modules": "^0.31.4",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-torch-nitro": "^0.0.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type AnimationType =
|
||||
| 'gradient'
|
||||
| 'pulse'
|
||||
| 'wave'
|
||||
| 'flash'
|
||||
| 'sos'
|
||||
| 'candle'
|
||||
| 'police'
|
||||
| 'warning'
|
||||
| 'disco'
|
||||
| 'thunder'
|
||||
| 'breath'
|
||||
| 'rave'
|
||||
| 'scanner'
|
||||
| 'matrix'
|
||||
| 'sunrise'
|
||||
| 'sunset';
|
||||
|
||||
export interface Mood {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: string[];
|
||||
animationType: AnimationType;
|
||||
isFavorite?: boolean;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export interface MoodSequenceItem {
|
||||
moodId: string;
|
||||
duration: number; // in Sekunden
|
||||
}
|
||||
|
||||
export interface MoodSequence {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MoodSequenceItem[];
|
||||
transitionDuration: number; // in Sekunden (2, 5, oder 10)
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
animationSpeed: number; // 0.5 = langsam, 1 = normal, 2 = schnell
|
||||
hapticFeedback: boolean;
|
||||
brightness: number; // 0-1 (Bildschirm-Helligkeit)
|
||||
autoTimer: number; // 0 = aus, sonst Minuten
|
||||
autoMoodSwitch: boolean;
|
||||
autoMoodSwitchInterval: number; // Minuten
|
||||
screenEnabled: boolean; // Bildschirm-Animation aktiviert
|
||||
flashlightEnabled: boolean; // Taschenlampe aktiviert
|
||||
flashlightBrightness: number; // 1-10 (Taschenlampen-Helligkeit)
|
||||
}
|
||||
|
||||
export interface MoodState {
|
||||
moods: Mood[];
|
||||
sequences: MoodSequence[];
|
||||
settings: Settings;
|
||||
addCustomMood: (mood: Omit<Mood, 'id'>) => void;
|
||||
removeMood: (id: string) => void;
|
||||
toggleFavorite: (id: string) => void;
|
||||
reorderMoods: (moods: Mood[]) => void;
|
||||
updateSettings: (settings: Partial<Settings>) => void;
|
||||
addSequence: (sequence: Omit<MoodSequence, 'id'>) => void;
|
||||
removeSequence: (id: string) => void;
|
||||
updateSequence: (id: string, sequence: Partial<MoodSequence>) => void;
|
||||
}
|
||||
|
||||
const defaultMoods: Mood[] = [
|
||||
{
|
||||
id: '7',
|
||||
name: 'Feuer',
|
||||
colors: ['#8B0000', '#FF4500', '#FF8C00', '#FFD700'],
|
||||
animationType: 'pulse',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
name: 'Atem',
|
||||
colors: ['#e3f2fd', '#90caf9', '#42a5f5'],
|
||||
animationType: 'breath',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
name: 'Nordlicht',
|
||||
colors: ['#00FF87', '#00D9FF', '#60EFFF', '#7B68EE', '#9D50BB'],
|
||||
animationType: 'wave',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Gewitter',
|
||||
colors: ['#2C3E50', '#34495E'],
|
||||
animationType: 'thunder',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Licht',
|
||||
colors: ['#FFFFFF', '#F5F5F5', '#E8E8E8'],
|
||||
animationType: 'gradient',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'Blitzlicht',
|
||||
colors: ['#000000', '#FFFFFF'],
|
||||
animationType: 'flash',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'SOS',
|
||||
colors: ['#000000', '#FF0000'],
|
||||
animationType: 'sos',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
name: 'Meer',
|
||||
colors: ['#006994', '#1E90FF', '#4682B4', '#87CEEB'],
|
||||
animationType: 'wave',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: 'Kerze',
|
||||
colors: ['#FF4500', '#FF6347', '#FF8C00', '#FFA500'],
|
||||
animationType: 'candle',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Polizei',
|
||||
colors: ['#0000FF', '#FF0000'],
|
||||
animationType: 'police',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'Warnsignal',
|
||||
colors: ['#FFA500', '#FFD700'],
|
||||
animationType: 'warning',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
name: 'Disco',
|
||||
colors: ['#FF00FF', '#00FFFF', '#FFFF00', '#FF0000', '#00FF00', '#0000FF'],
|
||||
animationType: 'disco',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
name: 'Sonnenaufgang',
|
||||
colors: [
|
||||
'#0f0c29',
|
||||
'#302b63', // Nacht
|
||||
'#1a1a2e',
|
||||
'#0f3460', // Früher Morgen
|
||||
'#434343',
|
||||
'#000000', // Dämmerung
|
||||
'#e94560',
|
||||
'#0f3460', // Rosa/Blau
|
||||
'#f39c12',
|
||||
'#f1c40f', // Gelb/Gold
|
||||
'#FDB99B',
|
||||
'#FCE38A', // Helles Gelb
|
||||
],
|
||||
animationType: 'sunrise',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
name: 'Sonnenuntergang',
|
||||
colors: [
|
||||
'#FDB99B',
|
||||
'#FCE38A', // Helles Gelb
|
||||
'#FF6B35',
|
||||
'#F7931E', // Orange
|
||||
'#e94560',
|
||||
'#f39c12', // Rot/Orange
|
||||
'#C1666B',
|
||||
'#8B5A8B', // Rosa/Lila
|
||||
'#4A235A',
|
||||
'#000428', // Dunkelviolett/Blau
|
||||
'#0f0c29',
|
||||
'#302b63', // Nacht
|
||||
],
|
||||
animationType: 'sunset',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
name: 'Wald',
|
||||
colors: ['#2d5016', '#3d7317', '#4a9d26', '#66bb6a', '#81c784'],
|
||||
animationType: 'gradient',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
name: 'Rave',
|
||||
colors: [
|
||||
'#FF00FF',
|
||||
'#00FFFF',
|
||||
'#FFFF00',
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FF6600',
|
||||
'#FF0080',
|
||||
],
|
||||
animationType: 'rave',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
name: 'Scanner',
|
||||
colors: ['#000000', '#FF0000'],
|
||||
animationType: 'scanner',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '24',
|
||||
name: 'Matrix',
|
||||
colors: ['#000000', '#00FF00'],
|
||||
animationType: 'matrix',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
animationSpeed: 1,
|
||||
hapticFeedback: true,
|
||||
brightness: 1,
|
||||
autoTimer: 0,
|
||||
autoMoodSwitch: false,
|
||||
autoMoodSwitchInterval: 5,
|
||||
screenEnabled: true,
|
||||
flashlightEnabled: false,
|
||||
flashlightBrightness: 10, // Maximale Helligkeit als Standard
|
||||
};
|
||||
|
||||
export const useStore = create<MoodState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
moods: defaultMoods,
|
||||
sequences: [],
|
||||
settings: defaultSettings,
|
||||
addCustomMood: (mood) =>
|
||||
set((state) => ({
|
||||
moods: [
|
||||
...state.moods,
|
||||
{ ...mood, id: Date.now().toString(), isCustom: true, isFavorite: false },
|
||||
],
|
||||
})),
|
||||
removeMood: (id) =>
|
||||
set((state) => ({
|
||||
moods: state.moods.filter((m) => m.id !== id),
|
||||
})),
|
||||
toggleFavorite: (id) =>
|
||||
set((state) => ({
|
||||
moods: state.moods.map((m) => (m.id === id ? { ...m, isFavorite: !m.isFavorite } : m)),
|
||||
})),
|
||||
reorderMoods: (moods) => set({ moods }),
|
||||
updateSettings: (newSettings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings },
|
||||
})),
|
||||
addSequence: (sequence) =>
|
||||
set((state) => ({
|
||||
sequences: [
|
||||
...state.sequences,
|
||||
{ ...sequence, id: Date.now().toString(), isCustom: true },
|
||||
],
|
||||
})),
|
||||
removeSequence: (id) =>
|
||||
set((state) => ({
|
||||
sequences: state.sequences.filter((s) => s.id !== id),
|
||||
})),
|
||||
updateSequence: (id, updates) =>
|
||||
set((state) => ({
|
||||
sequences: state.sequences.map((s) => (s.id === id ? { ...s, ...updates } : s)),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'mood-light-storage-v16',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'card-dark': '#2a2a2a',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// App ist permanent im Dark Mode mit komplett schwarzem Theme
|
||||
export const getThemeColors = () => {
|
||||
return {
|
||||
bg: 'bg-black',
|
||||
cardBg: 'bg-card-dark',
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-gray-400',
|
||||
border: 'border-gray-800',
|
||||
input: 'bg-gray-800',
|
||||
};
|
||||
};
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"name": "@moodlit/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"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",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "echo 'Skipping type-check for now'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/**
|
||||
* Feedback Service Instance for Moodlit Web App
|
||||
*/
|
||||
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
apiUrl: MANA_AUTH_URL,
|
||||
appId: 'moodlit',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
/**
|
||||
* Moodlit Web Auth Configuration
|
||||
*
|
||||
* This file initializes the shared auth package for the moodlit web app.
|
||||
*/
|
||||
|
||||
import { PUBLIC_MANA_CORE_AUTH_URL, PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
import {
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
setupFetchInterceptor,
|
||||
type StorageAdapter,
|
||||
type DeviceManagerAdapter,
|
||||
type NetworkAdapter,
|
||||
type DeviceInfo,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'moodlit_appToken',
|
||||
REFRESH_TOKEN: 'moodlit_refreshToken',
|
||||
USER_EMAIL: 'moodlit_userEmail',
|
||||
DEVICE_ID: 'moodlit_device_id',
|
||||
};
|
||||
|
||||
/**
|
||||
* Session storage adapter for moodlit web
|
||||
* Uses sessionStorage for tokens (clears on tab close)
|
||||
* Uses localStorage for device ID (persists)
|
||||
*/
|
||||
const sessionStorageAdapter: StorageAdapter = {
|
||||
async getItem<T = string>(key: string): Promise<T | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (value === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return value as T;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Device manager adapter for web
|
||||
*/
|
||||
const webDeviceAdapter: DeviceManagerAdapter = {
|
||||
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
deviceId: '',
|
||||
deviceName: 'Server',
|
||||
deviceType: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
const deviceId = (await webDeviceAdapter.getStoredDeviceId()) || generateDeviceId();
|
||||
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
let deviceName = 'Web Browser';
|
||||
|
||||
if (userAgent.includes('Mac')) deviceName = 'Mac';
|
||||
else if (userAgent.includes('Windows')) deviceName = 'Windows';
|
||||
else if (userAgent.includes('Linux')) deviceName = 'Linux';
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: 'web',
|
||||
platform: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async getStoredDeviceId(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Network adapter for web
|
||||
*/
|
||||
const webNetworkAdapter: NetworkAdapter = {
|
||||
async isDeviceConnected(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
},
|
||||
|
||||
async hasStableConnection(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique device ID
|
||||
*/
|
||||
function generateDeviceId(): string {
|
||||
return `moodlit_web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
// Initialize adapters
|
||||
setStorageAdapter(sessionStorageAdapter);
|
||||
setDeviceAdapter(webDeviceAdapter);
|
||||
setNetworkAdapter(webNetworkAdapter);
|
||||
|
||||
// Create auth service instance
|
||||
export const authService = createAuthService({
|
||||
baseUrl: PUBLIC_MANA_CORE_AUTH_URL,
|
||||
storageKeys: {
|
||||
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
|
||||
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
|
||||
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
|
||||
},
|
||||
endpoints: {
|
||||
signIn: '/api/v1/auth/login',
|
||||
signUp: '/api/v1/auth/register',
|
||||
signOut: '/api/v1/auth/logout',
|
||||
refresh: '/api/v1/auth/refresh',
|
||||
validate: '/api/v1/auth/validate',
|
||||
forgotPassword: '/api/v1/auth/forgot-password',
|
||||
googleSignIn: '/api/v1/auth/google-signin',
|
||||
appleSignIn: '/api/v1/auth/apple-signin',
|
||||
credits: '/api/v1/credits/balance',
|
||||
},
|
||||
});
|
||||
|
||||
// Create token manager instance
|
||||
export const tokenManager = createTokenManager(authService);
|
||||
|
||||
// Setup fetch interceptor (only in browser)
|
||||
if (typeof window !== 'undefined') {
|
||||
setupFetchInterceptor(authService, tokenManager, {
|
||||
backendUrl: PUBLIC_BACKEND_URL,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export useful utilities from shared-auth
|
||||
export {
|
||||
decodeToken,
|
||||
isTokenValidLocally,
|
||||
isTokenExpired,
|
||||
getUserFromToken,
|
||||
isB2BUser,
|
||||
getB2BInfo,
|
||||
TokenState,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
UserData,
|
||||
DecodedToken,
|
||||
AuthResult,
|
||||
CreditBalance,
|
||||
B2BInfo,
|
||||
} from '@manacore/shared-auth';
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import type { MoodlitUser } from '$lib/types/auth';
|
||||
import { authService, type UserData } from '$lib/auth';
|
||||
|
||||
// Svelte 5 runes-based auth store
|
||||
let user = $state<MoodlitUser | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
/**
|
||||
* Convert UserData from shared-auth to MoodlitUser
|
||||
*/
|
||||
function toMoodlitUser(userData: UserData | null): MoodlitUser | null {
|
||||
if (!userData) return null;
|
||||
return {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
};
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async initialize() {
|
||||
loading = true;
|
||||
try {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (isAuth) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toMoodlitUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set user
|
||||
*/
|
||||
setUser(newUser: MoodlitUser | null) {
|
||||
user = newUser;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
async checkAuth() {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (!isAuth) {
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const result = await authService.signIn(email, password);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toMoodlitUser(userData);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const result = await authService.signUp(email, password);
|
||||
if (result.success && !result.needsVerification) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toMoodlitUser(userData);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async forgotPassword(email: string) {
|
||||
return authService.forgotPassword(email);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
return authService.getAppToken();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('moodlit');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Get theme state
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Navigation items for Moodlit
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Moods', icon: 'palette' },
|
||||
{ href: '/sequences', label: 'Sequences', icon: 'layers' },
|
||||
{ href: '/settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...theme.variants.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant]?.label || variant,
|
||||
icon: THEME_DEFINITIONS[variant]?.icon || 'circle',
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Current theme variant label
|
||||
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant]?.label || theme.variant);
|
||||
|
||||
// Language selector items
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
sidebarModeStore.set(isSidebar);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('moodlit-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('moodlit-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('moodlit-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('moodlit-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if authStore.loading}
|
||||
<div class="min-h-screen flex items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if authStore.isAuthenticated}
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Moodlit"
|
||||
homeRoute="/"
|
||||
onLogout={handleSignOut}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<!-- Main content with dynamic padding -->
|
||||
<main
|
||||
class="transition-all duration-300 {isCollapsed
|
||||
? ''
|
||||
: isSidebarMode
|
||||
? 'pl-[180px]'
|
||||
: 'pt-20'}"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { moodsStore } from '$lib/stores/moods.svelte';
|
||||
import { DEFAULT_MOODS, getMoodGradient } from '$lib/data/default-moods';
|
||||
import MoodCard from '$lib/components/mood/MoodCard.svelte';
|
||||
import MoodFullscreen from '$lib/components/mood/MoodFullscreen.svelte';
|
||||
import CreateMoodDialog from '$lib/components/mood/CreateMoodDialog.svelte';
|
||||
import { Plus } from '@manacore/shared-icons';
|
||||
import type { Mood, AnimationType } from '$lib/types/mood';
|
||||
|
||||
// Combine default moods with custom moods
|
||||
let allMoods = $derived([...DEFAULT_MOODS, ...moodsStore.customMoods]);
|
||||
|
||||
// Get favorites (moods that are in the favorites list)
|
||||
let favoriteMoods = $derived(allMoods.filter((m) => moodsStore.isFavorite(m.id)));
|
||||
|
||||
// Filter by category
|
||||
let selectedCategory = $state<'all' | 'favorites' | 'custom'>('all');
|
||||
|
||||
// Fullscreen state
|
||||
let showFullscreen = $state(false);
|
||||
let fullscreenMood = $state<Mood | null>(null);
|
||||
|
||||
// Create mood dialog state
|
||||
let showCreateDialog = $state(false);
|
||||
|
||||
let displayedMoods = $derived(() => {
|
||||
switch (selectedCategory) {
|
||||
case 'favorites':
|
||||
return favoriteMoods;
|
||||
case 'custom':
|
||||
return moodsStore.customMoods;
|
||||
default:
|
||||
return allMoods;
|
||||
}
|
||||
});
|
||||
|
||||
function handleMoodClick(mood: Mood) {
|
||||
fullscreenMood = mood;
|
||||
showFullscreen = true;
|
||||
moodsStore.setActiveMood(mood);
|
||||
}
|
||||
|
||||
function handleCloseFullscreen() {
|
||||
showFullscreen = false;
|
||||
moodsStore.setActiveMood(null);
|
||||
}
|
||||
|
||||
function handleFavoriteToggle(mood: Mood) {
|
||||
moodsStore.toggleFavorite(mood.id);
|
||||
}
|
||||
|
||||
function handleFullscreenFavoriteToggle() {
|
||||
if (fullscreenMood) {
|
||||
moodsStore.toggleFavorite(fullscreenMood.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateMood(moodData: {
|
||||
name: string;
|
||||
colors: string[];
|
||||
animationType: AnimationType;
|
||||
}) {
|
||||
const newMood: Mood = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: moodData.name,
|
||||
colors: moodData.colors,
|
||||
animationType: moodData.animationType,
|
||||
isCustom: true,
|
||||
order: moodsStore.customMoods.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
moodsStore.addMood(newMood);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1 class="text-3xl font-bold">{$_('home.title')}</h1>
|
||||
<p class="text-[hsl(var(--color-muted-foreground))] mt-1">{$_('home.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
|
||||
'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'}"
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
>
|
||||
{$_('home.all')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
|
||||
'favorites'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'}"
|
||||
onclick={() => (selectedCategory = 'favorites')}
|
||||
>
|
||||
{$_('home.favorites')} ({favoriteMoods.length})
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
|
||||
'custom'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'}"
|
||||
onclick={() => (selectedCategory = 'custom')}
|
||||
>
|
||||
{$_('home.custom')} ({moodsStore.customMoods.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Mood View -->
|
||||
{#if showFullscreen && fullscreenMood}
|
||||
<MoodFullscreen
|
||||
mood={fullscreenMood}
|
||||
isFavorite={moodsStore.isFavorite(fullscreenMood.id)}
|
||||
onClose={handleCloseFullscreen}
|
||||
onFavoriteToggle={handleFullscreenFavoriteToggle}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Mood Grid -->
|
||||
<section>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each displayedMoods() as mood (mood.id)}
|
||||
<MoodCard
|
||||
{mood}
|
||||
isActive={moodsStore.activeMood?.id === mood.id}
|
||||
isFavorite={moodsStore.isFavorite(mood.id)}
|
||||
onClick={() => handleMoodClick(mood)}
|
||||
onFavoriteToggle={() => handleFavoriteToggle(mood)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if displayedMoods().length === 0}
|
||||
<div class="text-center py-12 text-muted-foreground">
|
||||
{#if selectedCategory === 'favorites'}
|
||||
<p>No favorites yet. Click the heart icon on a mood to add it to favorites.</p>
|
||||
{:else if selectedCategory === 'custom'}
|
||||
<p>No custom moods yet. Create your own mood to get started.</p>
|
||||
{:else}
|
||||
<p>No moods available.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Sequences Section -->
|
||||
<section class="border-t border-border pt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">{$_('home.sequences')}</h2>
|
||||
<a href="/sequences" class="text-sm text-primary hover:underline"> View all </a>
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_('home.sequencesDescription')}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed bottom-24 right-6 z-30 p-4 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 hover:scale-110 transition-all"
|
||||
onclick={() => (showCreateDialog = true)}
|
||||
aria-label={$_('createMood.title')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
<!-- Create Mood Dialog -->
|
||||
<CreateMoodDialog
|
||||
isOpen={showCreateDialog}
|
||||
onClose={() => (showCreateDialog = false)}
|
||||
onSave={handleCreateMood}
|
||||
/>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<FeedbackPage {feedbackService} appName="Moodlit" currentUserId={authStore.user?.id} />
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { sequencesStore } from '$lib/stores/sequences.svelte';
|
||||
import { DEFAULT_MOODS, getMoodById, getMoodGradient } from '$lib/data/default-moods';
|
||||
import { moodsStore } from '$lib/stores/moods.svelte';
|
||||
import { Play, Pause, Plus, Trash, Clock } from '@manacore/shared-icons';
|
||||
import type { MoodSequence, Mood } from '$lib/types/mood';
|
||||
import MoodFullscreen from '$lib/components/mood/MoodFullscreen.svelte';
|
||||
|
||||
// Get mood by ID from both default and custom moods
|
||||
function getMood(moodId: string): Mood | undefined {
|
||||
return getMoodById(moodId) || moodsStore.customMoods.find((m) => m.id === moodId);
|
||||
}
|
||||
|
||||
// Get sequence preview gradient (first 3 moods)
|
||||
function getSequenceGradient(sequence: MoodSequence): string {
|
||||
const colors = sequence.items.slice(0, 3).map((item) => {
|
||||
const mood = getMood(item.moodId);
|
||||
return mood?.colors[0] || '#8b5cf6';
|
||||
});
|
||||
return `linear-gradient(135deg, ${colors.join(', ')})`;
|
||||
}
|
||||
|
||||
// Get total duration
|
||||
function getTotalDuration(sequence: MoodSequence): number {
|
||||
return sequence.items.reduce((sum, item) => sum + item.duration, 0);
|
||||
}
|
||||
|
||||
// Format duration
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
}
|
||||
|
||||
// Active sequence player state
|
||||
let showPlayer = $state(false);
|
||||
let playerMood = $state<Mood | null>(null);
|
||||
let sequenceInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function playSequence(sequence: MoodSequence) {
|
||||
sequencesStore.playSequence(sequence);
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
function startPlayback() {
|
||||
if (!sequencesStore.activeSequence) return;
|
||||
|
||||
const currentItem = sequencesStore.activeSequence.items[sequencesStore.currentItemIndex];
|
||||
const mood = getMood(currentItem.moodId);
|
||||
|
||||
if (mood) {
|
||||
playerMood = mood;
|
||||
showPlayer = true;
|
||||
}
|
||||
|
||||
// Clear any existing interval
|
||||
if (sequenceInterval) clearInterval(sequenceInterval);
|
||||
|
||||
// Start timer for current item
|
||||
sequenceInterval = setInterval(() => {
|
||||
if (sequencesStore.isPlaying && sequencesStore.activeSequence) {
|
||||
sequencesStore.nextItem();
|
||||
const nextItem = sequencesStore.activeSequence.items[sequencesStore.currentItemIndex];
|
||||
const nextMood = getMood(nextItem.moodId);
|
||||
if (nextMood) {
|
||||
playerMood = nextMood;
|
||||
}
|
||||
}
|
||||
}, currentItem.duration * 1000);
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (sequenceInterval) {
|
||||
clearInterval(sequenceInterval);
|
||||
sequenceInterval = null;
|
||||
}
|
||||
sequencesStore.stopSequence();
|
||||
showPlayer = false;
|
||||
playerMood = null;
|
||||
}
|
||||
|
||||
function handlePlayerClose() {
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (sequenceInterval) clearInterval(sequenceInterval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">{$_('sequences.title')}</h1>
|
||||
<p class="text-muted-foreground mt-1">{$_('sequences.subtitle')}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sequences Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each sequencesStore.sequences as sequence (sequence.id)}
|
||||
<div
|
||||
class="relative rounded-2xl overflow-hidden transition-all hover:scale-[1.02] hover:shadow-lg"
|
||||
>
|
||||
<!-- Gradient Background -->
|
||||
<div class="aspect-video" style="background: {getSequenceGradient(sequence)};"></div>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"
|
||||
></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="absolute inset-0 p-4 flex flex-col justify-between">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2 text-white/80 text-sm">
|
||||
<Clock size={16} />
|
||||
{formatDuration(getTotalDuration(sequence))}
|
||||
</div>
|
||||
{#if sequence.isCustom}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-full bg-white/20 hover:bg-red-500/50 transition-colors"
|
||||
onclick={() => sequencesStore.removeSequence(sequence.id)}
|
||||
aria-label="Delete sequence"
|
||||
>
|
||||
<Trash size={16} class="text-white" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white drop-shadow-md">
|
||||
{sequence.name}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70">
|
||||
{sequence.items.length}
|
||||
{$_('sequences.moods')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="p-3 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-all hover:scale-110"
|
||||
onclick={() => playSequence(sequence)}
|
||||
aria-label="Play sequence"
|
||||
>
|
||||
<Play size={24} class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mood Preview Dots -->
|
||||
<div class="absolute bottom-16 left-4 flex gap-1">
|
||||
{#each sequence.items.slice(0, 5) as item}
|
||||
{@const mood = getMood(item.moodId)}
|
||||
{#if mood}
|
||||
<div
|
||||
class="w-4 h-4 rounded-full border-2 border-white/50"
|
||||
style="background: {mood.colors[0]};"
|
||||
title={mood.name}
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if sequence.items.length > 5}
|
||||
<div
|
||||
class="w-4 h-4 rounded-full bg-white/30 flex items-center justify-center text-[8px] text-white font-bold"
|
||||
>
|
||||
+{sequence.items.length - 5}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if sequencesStore.sequences.length === 0}
|
||||
<section class="bg-muted/50 rounded-2xl p-8 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/20 flex items-center justify-center"
|
||||
>
|
||||
<Plus size={32} class="text-primary" />
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-2">{$_('sequences.empty')}</h2>
|
||||
<p class="text-muted-foreground">{$_('sequences.emptyDescription')}</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sequence Player (Fullscreen) -->
|
||||
{#if showPlayer && playerMood}
|
||||
<MoodFullscreen
|
||||
mood={playerMood}
|
||||
isFavorite={moodsStore.isFavorite(playerMood.id)}
|
||||
onClose={handlePlayerClose}
|
||||
onFavoriteToggle={() => moodsStore.toggleFavorite(playerMood?.id || '')}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { MoodlitLogo } from '@manacore/shared-branding';
|
||||
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
|
||||
|
||||
async function handleForgotPassword(email: string) {
|
||||
return authStore.forgotPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Moodlit"
|
||||
logo={MoodlitLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onForgotPassword={handleForgotPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { MoodlitLogo } from '@manacore/shared-branding';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="Moodlit"
|
||||
logo={MoodlitLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/"
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { MoodlitLogo } from '@manacore/shared-branding';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<RegisterPage
|
||||
appName="Moodlit"
|
||||
logo={MoodlitLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-[hsl(var(--color-background))] text-[hsl(var(--color-foreground))]">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5182,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"name": "moodlit",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Moodlit - Ambient lighting & mood app",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "pnpm --filter @moodlit/backend dev",
|
||||
"dev:web": "pnpm --filter @moodlit/web dev",
|
||||
"dev:mobile": "pnpm --filter @moodlit/mobile dev",
|
||||
"dev:landing": "pnpm --filter @moodlit/landing dev",
|
||||
"db:push": "pnpm --filter @moodlit/backend db:push",
|
||||
"db:studio": "pnpm --filter @moodlit/backend db:studio"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
37
apps/moodlit/CLAUDE.md
Normal file
37
apps/moodlit/CLAUDE.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Moodlit — Ambient Lighting & Mood App
|
||||
|
||||
## Architecture
|
||||
|
||||
Local-first for moods/sequences, Hono/Bun server for preset library.
|
||||
|
||||
```
|
||||
Browser → IndexedDB (Moods, Sequences)
|
||||
↕ sync
|
||||
mana-sync → PostgreSQL
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/moodlit/
|
||||
├── apps/
|
||||
│ ├── web/ # SvelteKit web app (local-first)
|
||||
│ ├── server/ # Hono/Bun (preset moods API)
|
||||
│ └── landing/ # Astro landing page
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:moodlit:web # SvelteKit dev server
|
||||
pnpm dev:moodlit:server # Hono/Bun server (port 3073)
|
||||
pnpm dev:moodlit:landing # Landing page
|
||||
```
|
||||
|
||||
## Local-First Collections
|
||||
|
||||
| Collection | Fields |
|
||||
|-----------|--------|
|
||||
| `moods` | name, colors (hex array), animation, isDefault |
|
||||
| `sequences` | name, moodIds, duration (seconds) |
|
||||
|
|
@ -6,14 +6,14 @@ export default defineConfig({
|
|||
integrations: [tailwind()],
|
||||
output: 'static',
|
||||
build: {
|
||||
inlineStylesheets: 'auto'
|
||||
inlineStylesheets: 'auto',
|
||||
},
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@components': '/src/components',
|
||||
'@layouts': '/src/layouts'
|
||||
}
|
||||
}
|
||||
}
|
||||
'@layouts': '/src/layouts',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
21
apps/moodlit/apps/server/package.json
Normal file
21
apps/moodlit/apps/server/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@moodlit/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^6.1.2",
|
||||
"postgres": "^3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
16
apps/moodlit/apps/server/src/config.ts
Normal file
16
apps/moodlit/apps/server/src/config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export interface Config {
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
manaAuthUrl: string;
|
||||
cors: { origins: string[] };
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3073', 10),
|
||||
databaseUrl:
|
||||
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_sync',
|
||||
manaAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
|
||||
cors: { origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',') },
|
||||
};
|
||||
}
|
||||
17
apps/moodlit/apps/server/src/index.ts
Normal file
17
apps/moodlit/apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { loadConfig } from './config';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { presetRoutes } from './routes/presets';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
||||
|
||||
app.route('/health', healthRoutes);
|
||||
app.route('/api/v1/presets', presetRoutes);
|
||||
|
||||
export default { port: config.port, fetch: app.fetch };
|
||||
19
apps/moodlit/apps/server/src/lib/errors.ts
Normal file
19
apps/moodlit/apps/server/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export class NotFoundError extends HTTPException {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends HTTPException {
|
||||
constructor(message = 'Bad request') {
|
||||
super(400, { message });
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HTTPException {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(401, { message });
|
||||
}
|
||||
}
|
||||
11
apps/moodlit/apps/server/src/middleware/error-handler.ts
Normal file
11
apps/moodlit/apps/server/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { ErrorHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
export const errorHandler: ErrorHandler = (err, c) => {
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({ statusCode: err.status, message: err.message }, err.status);
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
|
||||
};
|
||||
46
apps/moodlit/apps/server/src/middleware/jwt-auth.ts
Normal file
46
apps/moodlit/apps/server/src/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { MiddlewareHandler } from 'hono';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { UnauthorizedError } from '../lib/errors';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks(authUrl: string) {
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||
issuer: authUrl,
|
||||
audience: 'manacore',
|
||||
});
|
||||
|
||||
const user: AuthUser = {
|
||||
userId: payload.sub || '',
|
||||
email: (payload.email as string) || '',
|
||||
role: (payload.role as string) || 'user',
|
||||
};
|
||||
|
||||
c.set('user', user);
|
||||
await next();
|
||||
} catch {
|
||||
throw new UnauthorizedError('Invalid or expired token');
|
||||
}
|
||||
};
|
||||
}
|
||||
10
apps/moodlit/apps/server/src/routes/health.ts
Normal file
10
apps/moodlit/apps/server/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono().get('/', (c) =>
|
||||
c.json({
|
||||
status: 'ok',
|
||||
service: 'moodlit-server',
|
||||
runtime: 'bun',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue