commit 3f9bc1f484fb155321d77034c460972fbb62bcfa Author: lotherk Date: Thu Mar 26 19:57:20 2026 +0000 Initial commit: deardiary project setup diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..960133c --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +dist/ +build/ + +# Data +data/ +*.db +*.db-journal + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ + +# Misc +*.tgz diff --git a/Dockerfile.old b/Dockerfile.old new file mode 100644 index 0000000..06b039a --- /dev/null +++ b/Dockerfile.old @@ -0,0 +1,46 @@ +# Multi-stage build: Backend + Frontend +FROM oven/bun:1.1-alpine AS backend-builder + +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN bun install +COPY backend/prisma ./prisma +RUN bunx prisma generate +COPY backend/src ./src +RUN bun build src/index.ts --outdir ./dist --target bun + +FROM node:20-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend ./ +RUN npm run build + +FROM oven/bun:1.1-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs || true + +# Install nginx for serving frontend +RUN apk add --no-cache nginx + +# Copy backend +COPY --from=backend-builder /app/backend/dist ./dist +COPY --from=backend-builder /app/backend/node_modules ./node_modules +COPY backend/package.json . +COPY backend/prisma ./prisma + +# Copy frontend build +COPY --from=frontend-builder /app/frontend/dist ./public + +# Setup nginx +COPY nginx.conf /etc/nginx/http.d/default.conf + +RUN mkdir -p /data /run /app/nginx_tmp /var/lib/nginx/logs && chmod 777 /var/lib/nginx/logs /var/lib/nginx/tmp && chown -R bun:nodejs /data /app + +# Start everything as root +CMD sh -c "nginx -g 'daemon off;' & bunx prisma db push --accept-data-loss & bun ./dist/index.js" diff --git a/PLAN.md.old b/PLAN.md.old new file mode 100644 index 0000000..a3ffbb1 --- /dev/null +++ b/PLAN.md.old @@ -0,0 +1,444 @@ +# TotalRecall - AI-Powered Daily Journal + +> Your day, analyzed. A journal that writes itself. + +--- + +## Concept + +TotalRecall is a privacy-first, self-hostable daily journal application that captures life through multiple input methods (text, photos, voice, data) and uses AI to generate thoughtful, reflective journal entries at the end of each day. + +The core philosophy: **your data is yours**. Whether you host it yourself or use a hosted service, you own everything. The same codebase runs everywhere. + +--- + +## The Problem + +- Most journaling apps are siloed, paid, or data-mining platforms +- Manual journaling is time-consuming and inconsistent +- People capture moments (photos, voice memos, scattered notes) but never reflect on them +- AI tools exist but require manual input of context + +## The Solution + +TotalRecall aggregates all your daily inputs throughout the day, then at your command (or scheduled), sends everything to an AI to synthesize your day into a coherent, reflective journal entry. + +--- + +## Features + +### Input Capture + +| Type | Description | Storage | +|------|-------------|---------| +| **Text Notes** | Quick thoughts, feelings, observations | Markdown | +| **Photos** | Camera or gallery uploads | JPEG/PNG in media folder | +| **Voice Memos** | Audio recordings | WebM/Opus | +| **Location** | Optional GPS tagging of entries | Lat/long metadata | +| **Health Data** | Manual entry (steps, mood, sleep) | JSON metadata | + +### AI Journal Generation + +- Aggregates all entries for a day +- Sends to configurable AI provider +- Generates reflective, narrative journal entry +- Stores generated journal alongside raw entries + +### AI Provider Support (Pluggable) + +- **OpenAI** - GPT-4, GPT-4-Turbo, GPT-3.5 +- **Anthropic** - Claude 3 Opus, Sonnet, Haiku +- **Ollama** - Local LLM (Llama, Mistral, etc.) +- **LM Studio** - Local LLM with OpenAI-compatible API + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ End User │ +│ │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ Web App │ │ Mobile App │ │ +│ │ (React/PWA) │ │ (Future: iOS) │ │ +│ └─────────┬──────────┘ └─────────┬──────────┘ │ +│ │ │ │ +│ └──────────┬─────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ REST API │ │ +│ │ /api/v1/* │ │ +│ │ │ │ +│ │ - Authentication │ │ +│ │ - Entries CRUD │ │ +│ │ - Journal Gen │ │ +│ │ - Settings │ │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ SQLite Database │ │ +│ │ + Media Storage │ │ +│ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Tech Stack + +| Layer | Technology | Reason | +|-------|------------|--------| +| Backend Runtime | **Bun** | Fast startup, native TypeScript, small Docker image | +| API Framework | **Hono** | Lightweight, fast, works everywhere | +| Database | **SQLite / MySQL / PostgreSQL** (via Prisma ORM) | Switch databases with one line, zero-config for SQLite | +| Frontend | **React + Vite** | Fast dev, PWA capable | +| Styling | **Tailwind CSS** | Rapid UI development | +| Container | **Docker + Docker Compose** | One-command deployment | + +--- + +## Deployment Modes + +TotalRecall runs the same codebase whether self-hosted or on a SaaS platform. + +### Single-User (Self-Hosted) + +```yaml +services: + totalrecall: + image: ghcr.io/totalrecall/totalrecall:latest + ports: + - "3000:3000" + volumes: + - ./data:/data +``` + +- Users create accounts locally +- No external dependencies +- One Docker command to run + +### Multi-Tenant (SaaS) + +```yaml +services: + totalrecall: + image: ghcr.io/totalrecall/totalrecall:latest + ports: + - "3000:3000" + environment: + - DEPLOYMENT_MODE=multi-tenant + - JWT_SECRET=${JWT_SECRET} + - DATABASE_URL=file:/data/totalrecall.db + volumes: + - ./data:/data +``` + +- Multiple users with accounts +- API key authentication +- Same features as self-hosted + +--- + +## API Design + +### Authentication + +``` +Authorization: Bearer {api_key} +``` + +All requests (except auth endpoints) require a valid API key. + +### Endpoints + +``` +POST /api/v1/auth/register # Create account +POST /api/v1/auth/login # Get API key +POST /api/v1/auth/logout # Invalidate session + +GET /api/v1/days # List days with entries +GET /api/v1/days/:date # Get day's data (entries + journal) +DELETE /api/v1/days/:date # Delete day and all entries + +POST /api/v1/entries # Create entry +GET /api/v1/entries/:id # Get entry +PUT /api/v1/entries/:id # Update entry +DELETE /api/v1/entries/:id # Delete entry + +POST /api/v1/entries/:id/photo # Upload photo to entry +POST /api/v1/entries/:id/voice # Upload voice to entry + +POST /api/v1/journal/generate/:date # Generate AI journal for date +GET /api/v1/journal/:date # Get generated journal + +GET /api/v1/settings # Get user settings +PUT /api/v1/settings # Update settings (AI provider, etc.) + +GET /api/v1/health # Health check (no auth) +``` + +### Response Format + +```typescript +// Success +{ "data": { ... }, "error": null } + +// Error +{ "data": null, "error": { "code": "NOT_FOUND", "message": "Entry not found" } } +``` + +--- + +## Data Models + +### User + +```typescript +interface User { + id: string; // UUID + email: string; // Unique + passwordHash: string; // bcrypt + createdAt: string; // ISO timestamp +} +``` + +### API Key + +```typescript +interface ApiKey { + id: string; + userId: string; + keyHash: string; // SHA-256 hash, not stored plaintext + name: string; // "iPhone", "Web", etc. + lastUsedAt: string; + createdAt: string; +} +``` + +### Entry + +```typescript +type EntryType = 'text' | 'voice' | 'photo' | 'health' | 'location'; + +interface Entry { + id: string; + userId: string; + date: string; // YYYY-MM-DD + type: EntryType; + content: string; // Text or metadata JSON + mediaPath?: string; // Path to uploaded file + metadata?: { + source?: 'manual' | 'health' | 'calendar'; + location?: { lat: number; lng: number }; + duration?: number; // For voice entries + [key: string]: unknown; + }; + createdAt: string; +} +``` + +### Journal + +```typescript +interface Journal { + id: string; + userId: string; + date: string; // YYYY-MM-DD + content: string; // Markdown + entryCount: number; // How many entries were used + generatedAt: string; +} +``` + +### Settings + +```typescript +interface Settings { + userId: string; + aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; + aiConfig: { + // Provider-specific config (API keys stored encrypted) + model?: string; + baseUrl?: string; // For Ollama/LM Studio + }; + journalPrompt?: string; + language: string; +} +``` + +--- + +## File Storage + +``` +/data/ +├── totalrecall.db # SQLite database +└── media/ + └── {user_id}/ + └── {date}/ + ├── entry-{uuid}.webm # Voice recordings + └── entry-{uuid}.jpg # Photos +``` + +--- + +## Project Structure + +``` +totalrecall/ +├── backend/ +│ ├── src/ +│ │ ├── index.ts # Entry point +│ │ ├── app.ts # Hono app setup +│ │ ├── routes/ +│ │ │ ├── auth.ts # Registration, login +│ │ │ ├── days.ts # Day operations +│ │ │ ├── entries.ts # Entry CRUD +│ │ │ ├── journal.ts # Journal generation +│ │ │ └── settings.ts # User settings +│ │ ├── services/ +│ │ │ ├── db.ts # Database connection +│ │ │ ├── storage.ts # File storage operations +│ │ │ └── ai/ +│ │ │ ├── mod.ts # Provider interface +│ │ │ ├── openai.ts +│ │ │ ├── anthropic.ts +│ │ │ └── ollama.ts +│ │ ├── middleware/ +│ │ │ └── auth.ts # API key validation +│ │ └── types.ts +│ ├── prisma/ +│ │ └── schema.prisma # Database schema +│ ├── Dockerfile +│ └── package.json +│ +├── frontend/ +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── pages/ +│ │ │ ├── Home.tsx # Today's view +│ │ │ ├── History.tsx # Browse past days +│ │ │ ├── Journal.tsx # View generated journals +│ │ │ └── Settings.tsx # Configuration +│ │ ├── components/ +│ │ │ ├── EntryInput.tsx +│ │ │ ├── PhotoCapture.tsx +│ │ │ ├── VoiceRecorder.tsx +│ │ │ ├── EntryList.tsx +│ │ │ └── JournalView.tsx +│ │ ├── lib/ +│ │ │ └── api.ts # API client +│ │ └── hooks/ +│ ├── Dockerfile +│ └── package.json +│ +├── docker-compose.yml # Full stack +├── docker-compose.prod.yml # Production overrides +├── Dockerfile # Multi-stage build +└── README.md +``` + +--- + +## Implementation Phases + +### Phase 1: Backend Foundation ✅ +- [x] Bun + Hono setup +- [x] Prisma ORM with SQLite (dev) / PostgreSQL (prod) +- [x] User registration & login (JWT) +- [x] API key authentication +- [x] Basic CRUD routes + +### Phase 2: Data Management ✅ +- [x] Entry CRUD with media support +- [x] File upload handling (photos, voice) +- [x] Day aggregation queries +- [x] Media file storage + +### Phase 3: AI Integration ✅ +- [x] AI provider interface +- [x] OpenAI implementation +- [x] Anthropic implementation +- [x] Ollama implementation +- [x] Journal generation endpoint + +### Phase 4: Frontend Core ✅ +- [x] React + Vite setup +- [x] API client library +- [x] Authentication flow +- [x] Home page (today's entries) +- [x] Entry creation (text input) + +### Phase 5: Media Inputs ✅ +- [x] Photo capture/upload +- [x] Voice recording +- [x] Entry list with media preview + +### Phase 6: Journal & Settings ✅ +- [x] Journal viewing +- [x] Settings page +- [x] AI provider configuration +- [x] Prompt customization + +### Phase 7: Deployment ✅ +- [x] Docker multi-stage build +- [x] Docker Compose setup +- [x] Health checks +- [ ] PWA manifest + +### Phase 8: Future +- [ ] iOS app (SwiftUI) +- [ ] Android app (Kotlin) +- [ ] Calendar integration +- [ ] Health data sync + +--- + +## Security Considerations + +- API keys hashed with SHA-256 (never stored plaintext) +- Passwords hashed with bcrypt +- CORS configurable for domain restriction +- Rate limiting on auth endpoints +- File upload validation (type, size) +- SQL injection prevention via ORM + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `file:./data/totalrecall.db` | SQLite/PostgreSQL/MySQL connection | +| `MEDIA_DIR` | `./data/media` | Directory for uploaded files | +| `JWT_SECRET` | (required) | Secret for JWT signing | +| `PORT` | `3000` | Server port | +| `CORS_ORIGIN` | `*` | Allowed origins | +| `RATE_LIMIT` | `100/hour` | Auth endpoint rate limit | + +### Database Connection Examples + +```bash +# SQLite (development, default) +DATABASE_URL="file:./data/totalrecall.db" + +# PostgreSQL (production) +DATABASE_URL="postgresql://user:password@localhost:5432/totalrecall" + +# MySQL +DATABASE_URL="mysql://user:password@localhost:3306/totalrecall" +``` + +--- + +## Licensing + +This project is open source under [MIT License](LICENSE). You are free to: +- Use it for yourself +- Host it for others +- Modify and redistribute + +No attribution required (but appreciated). diff --git a/README.md b/README.md new file mode 100644 index 0000000..d35f8ec --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# DearDiary + +> Your day, analyzed. A journal that writes itself. + +AI-powered daily journal that captures life through multiple input methods and generates thoughtful, reflective journal entries. + +## Features + +- **Multiple Input Types**: Text notes, photos, voice memos, health data +- **AI Journal Generation**: OpenAI, Anthropic, Ollama, or LM Studio +- **Self-Hostable**: Run it yourself or use any hosted version +- **Same Codebase**: Single deployment works for single-user or multi-tenant +- **Task History**: Full logging of AI requests and responses + +## Quick Start + +### Docker (Recommended) + +```bash +git clone https://github.com/your-repo/deardiary.git +cd deardiary + +# Create .env file +cp backend/.env.example .env +# Edit .env and set JWT_SECRET + +# Start +docker compose up -d +``` + +Visit http://localhost:5173 + +### Manual Development + +```bash +# Backend +cd backend +bun install +bunx prisma generate +bunx prisma db push +bun run dev + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `file:./data/deardiary.db` | SQLite, PostgreSQL, or MySQL | +| `JWT_SECRET` | (required) | Secret for JWT signing | +| `MEDIA_DIR` | `./data/media` | Directory for uploads | +| `PORT` | `3000` | Server port | +| `CORS_ORIGIN` | `*` | Allowed origins | + +### Database Examples + +```bash +# SQLite (default) +DATABASE_URL="file:./data/deardiary.db" + +# PostgreSQL +DATABASE_URL="postgresql://user:pass@host:5432/deardiary" + +# MySQL +DATABASE_URL="mysql://user:pass@host:3306/deardiary" +``` + +## AI Providers + +Configure in Settings after logging in: + +| Provider | Setup | +|----------|-------| +| **OpenAI** | API key required | +| **Anthropic** | API key required | +| **Ollama** | Local URL (default: http://localhost:11434) | +| **LM Studio** | Local URL (default: http://localhost:1234/v1) | + +## Project Structure + +``` +deardiary/ +├── backend/ # Bun + Hono API server +│ ├── src/ +│ │ ├── routes/ # API endpoints +│ │ ├── services/ # AI providers +│ │ └── middleware/ +│ └── prisma/ # Database schema +├── frontend/ # React + Vite web app +├── android/ # Native Android app (Kotlin + Compose) +├── docker-compose.yml +└── PLAN.md # Full specification +``` + +## API + +All endpoints require `Authorization: Bearer {api_key}` header. + +``` +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/api-key + +GET /api/v1/days +GET /api/v1/days/:date +DELETE /api/v1/days/:date + +POST /api/v1/entries +GET /api/v1/entries/:id +PUT /api/v1/entries/:id +DELETE /api/v1/entries/:id +POST /api/v1/entries/:id/photo +POST /api/v1/entries/:id/voice + +POST /api/v1/journal/generate/:date +GET /api/v1/journal/:date +GET /api/v1/journal/:date/tasks + +GET /api/v1/settings +PUT /api/v1/settings +``` + +## License + +MIT diff --git a/README.md.old b/README.md.old new file mode 100644 index 0000000..694d72e --- /dev/null +++ b/README.md.old @@ -0,0 +1,130 @@ +# TotalRecall + +> Your day, analyzed. A journal that writes itself. + +AI-powered daily journal that captures life through multiple input methods and generates thoughtful, reflective journal entries. + +## Features + +- **Multiple Input Types**: Text notes, photos, voice memos, health data +- **AI Journal Generation**: OpenAI, Anthropic, Ollama, or LM Studio +- **Self-Hostable**: Run it yourself or use any hosted version +- **Same Codebase**: Single deployment works for single-user or multi-tenant + +## Quick Start + +### Docker (Recommended) + +```bash +git clone https://github.com/totalrecall/totalrecall.git +cd totalrecall + +# Create .env file +cp backend/.env.example .env +# Edit .env and set JWT_SECRET + +# Start +docker-compose up -d +``` + +Visit http://localhost:5173 + +### Manual Development + +```bash +# Backend +cd backend +bun install +bunx prisma generate +bunx prisma db push +bun run dev + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `file:./data/totalrecall.db` | SQLite, PostgreSQL, or MySQL | +| `JWT_SECRET` | (required) | Secret for JWT signing | +| `MEDIA_DIR` | `./data/media` | Directory for uploads | +| `PORT` | `3000` | Server port | +| `CORS_ORIGIN` | `*` | Allowed origins | + +### Database Examples + +```bash +# SQLite (default) +DATABASE_URL="file:./data/totalrecall.db" + +# PostgreSQL +DATABASE_URL="postgresql://user:pass@host:5432/totalrecall" + +# MySQL +DATABASE_URL="mysql://user:pass@host:3306/totalrecall" +``` + +## AI Providers + +Configure in Settings after logging in: + +| Provider | Setup | +|----------|-------| +| **OpenAI** | API key required | +| **Anthropic** | API key required | +| **Ollama** | Local URL (default: http://localhost:11434) | +| **LM Studio** | Local URL (default: http://localhost:1234/v1) | + +## Project Structure + +``` +totalrecall/ +├── backend/ # Bun + Hono API server +│ ├── src/ +│ │ ├── routes/ # API endpoints +│ │ ├── services/ # AI providers +│ │ └── middleware/ +│ └── prisma/ # Database schema +├── frontend/ # React + Vite web app +├── android/ # Native Android app (Kotlin + Compose) +│ └── app/src/main/java/com/totalrecall/ +├── docker-compose.yml +└── PLAN.md # Full specification +``` + +## API + +All endpoints require `Authorization: Bearer {api_key}` header. + +``` +POST /api/v1/auth/register +POST /api/v1/auth/login +POST /api/v1/auth/api-key + +GET /api/v1/days +GET /api/v1/days/:date +DELETE /api/v1/days/:date + +POST /api/v1/entries +GET /api/v1/entries/:id +PUT /api/v1/entries/:id +DELETE /api/v1/entries/:id +POST /api/v1/entries/:id/photo +POST /api/v1/entries/:id/voice + +POST /api/v1/journal/generate/:date +GET /api/v1/journal/:date + +GET /api/v1/settings +PUT /api/v1/settings +``` + +## License + +MIT diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..dd392b5 --- /dev/null +++ b/android/README.md @@ -0,0 +1,73 @@ +# Android App + +Native Android app using Kotlin and Jetpack Compose that connects to the same TotalRecall API. + +## Requirements + +- Android Studio Hedgehog or newer +- Android SDK 34 +- Kotlin 1.9+ +- Java 17 + +## Building + +1. Open Android Studio +2. File > Open > select the `android` folder +3. Wait for Gradle sync to complete +4. Build > Build APK + +Or from command line: + +```bash +cd android +./gradlew assembleDebug +``` + +The APK will be at: `app/build/outputs/apk/debug/app-debug.apk` + +## Configuration + +By default, the app connects to `http://10.0.2.2:3000/api/v1/` (localhost for Android emulator). + +To change the API URL, edit `app/build.gradle.kts`: + +```kotlin +buildConfigField("String", "API_BASE_URL", "\"http://your-server:3000/api/v1/\"") +``` + +## Features + +- User registration and login +- Create text entries +- Voice memos +- Health check-ins +- View history by day +- Generate AI journal +- Configure AI provider (OpenAI, Anthropic, Ollama, LM Studio) + +## Project Structure + +``` +android/ +├── app/src/main/java/com/totalrecall/ +│ ├── api/ # API client +│ ├── model/ # Data models +│ ├── repository/ # Repository pattern +│ ├── viewmodel/ # ViewModels +│ └── ui/ # Compose UI screens +│ ├── auth/ +│ ├── home/ +│ ├── history/ +│ ├── journal/ +│ └── settings/ +├── build.gradle.kts +└── settings.gradle.kts +``` + +## Screenshots + +Coming soon... + +## License + +MIT diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..4affa6d --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.totalrecall" + compileSdk = 34 + + defaultConfig { + applicationId = "com.totalrecall" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000/api/v1/\"") + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.1") + + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + implementation("androidx.navigation:navigation-compose:2.7.5") + + implementation("com.google.code.gson:gson:2.10.1") + implementation("androidx.datastore:datastore-preferences:1.0.0") + + implementation("io.coil-kt:coil-compose:2.5.0") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..08c4799 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/totalrecall/MainActivity.kt b/android/app/src/main/java/com/totalrecall/MainActivity.kt new file mode 100644 index 0000000..fe03ac0 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/MainActivity.kt @@ -0,0 +1,29 @@ +package com.totalrecall + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import com.totalrecall.ui.AppNavigation +import com.totalrecall.ui.TotalRecallTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + TotalRecallTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AppNavigation() + } + } + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/api/ApiClient.kt b/android/app/src/main/java/com/totalrecall/api/ApiClient.kt new file mode 100644 index 0000000..768236e --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/api/ApiClient.kt @@ -0,0 +1,181 @@ +package com.totalrecall.api + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +data class ApiResponse( + val data: T?, + val error: ApiError? +) + +data class ApiError( + val code: String, + val message: String +) + +data class User( + val id: String, + val email: String, + @SerializedName("createdAt") val createdAt: String? = null +) + +data class LoginResponse( + val token: String, + val userId: String +) + +data class CreateApiKeyResponse( + val apiKey: String, + val id: String, + val name: String +) + +data class Entry( + val id: String, + val date: String, + val type: String, + val content: String, + val mediaPath: String? = null, + val metadata: String? = null, + val createdAt: String +) + +data class Journal( + val id: String? = null, + val date: String, + val content: String, + val entryCount: Int, + val generatedAt: String +) + +data class DayInfo( + val date: String, + val entryCount: Int, + val hasJournal: Boolean, + val journalGeneratedAt: String? = null +) + +data class DayResponse( + val date: String, + val entries: List, + val journal: Journal? +) + +data class Settings( + val aiProvider: String = "openai", + val aiApiKey: String? = null, + val aiModel: String = "gpt-4", + val aiBaseUrl: String? = null, + val journalPrompt: String = "You are a thoughtful journal writer.", + val language: String = "en" +) + +class ApiClient(private var baseUrl: String) { + private var apiKey: String? = null + private val gson = Gson() + + fun setApiKey(key: String) { + apiKey = key + } + + fun getApiKey(): String? = apiKey + + fun clearApiKey() { + apiKey = null + } + + private suspend fun request( + method: String, + path: String, + body: Any? = null, + authenticated: Boolean = true + ): ApiResponse = withContext(Dispatchers.IO) { + try { + val url = URL("$baseUrl$path") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = method + conn.setRequestProperty("Content-Type", "application/json") + + if (authenticated && apiKey != null) { + conn.setRequestProperty("Authorization", "Bearer $apiKey") + } + + if (body != null) { + conn.doOutput = true + conn.outputStream.write(gson.toJson(body).toByteArray()) + } + + val responseCode = conn.responseCode + val reader = BufferedReader(InputStreamReader( + if (responseCode in 200..299) conn.inputStream else conn.errorStream + )) + val response = reader.readText() + reader.close() + + val type = object : com.google.gson.reflect.TypeToken>() {}.type + gson.fromJson(response, type) + } catch (e: Exception) { + ApiResponse(null, ApiError("NETWORK_ERROR", e.message ?: "Unknown error")) + } + } + + suspend fun register(email: String, password: String): ApiResponse> { + return request("POST", "auth/register", mapOf("email" to email, "password" to password), false) + } + + suspend fun login(email: String, password: String): ApiResponse { + return request("POST", "auth/login", mapOf("email" to email, "password" to password), false) + } + + suspend fun createApiKey(name: String, token: String): ApiResponse { + return request("POST", "auth/api-key", mapOf("name" to name), authenticated = true).let { resp -> + if (resp.data != null) { + ApiResponse(resp.data, null) + } else { + resp + } + } + } + + suspend fun getDays(): ApiResponse> { + return request("GET", "days") + } + + suspend fun getDay(date: String): ApiResponse { + return request("GET", "days/$date") + } + + suspend fun createEntry(date: String, type: String, content: String): ApiResponse { + return request("POST", "entries", mapOf( + "date" to date, + "type" to type, + "content" to content + )) + } + + suspend fun deleteEntry(id: String): ApiResponse> { + return request("DELETE", "entries/$id") + } + + suspend fun generateJournal(date: String): ApiResponse { + return request("POST", "journal/generate/$date") + } + + suspend fun getJournal(date: String): ApiResponse { + return request("GET", "journal/$date") + } + + suspend fun getSettings(): ApiResponse { + return request("GET", "settings") + } + + suspend fun updateSettings(settings: Settings): ApiResponse { + return request("PUT", "settings", settings) + } +} diff --git a/android/app/src/main/java/com/totalrecall/repository/Repository.kt b/android/app/src/main/java/com/totalrecall/repository/Repository.kt new file mode 100644 index 0000000..5f7b9f7 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/repository/Repository.kt @@ -0,0 +1,114 @@ +package com.totalrecall.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.totalrecall.api.* + +private val Context.dataStore: DataStore by preferencesDataStore(name = "totalrecall") + +class Repository(context: Context, private val baseUrl: String) { + private val api = ApiClient(baseUrl) + private val dataStore = context.dataStore + + companion object { + private val API_KEY = stringPreferencesKey("api_key") + private val USER_EMAIL = stringPreferencesKey("user_email") + } + + suspend fun loadSavedState() { + dataStore.data.collect { prefs -> + prefs[API_KEY]?.let { api.setApiKey(it) } + } + } + + suspend fun isLoggedIn(): Boolean = api.getApiKey() != null + + suspend fun register(email: String, password: String): Result { + val response = api.register(email, password) + return if (response.data != null) Result.success(Unit) + else Result.failure(Exception(response.error?.message ?: "Registration failed")) + } + + suspend fun login(email: String, password: String): Result { + val response = api.login(email, password) + return if (response.data != null) { + val loginResponse = api.login(email, password) + if (loginResponse.data != null) { + val token = loginResponse.data.token + val keyResponse = api.createApiKey("Android App", token) + if (keyResponse.data != null) { + api.setApiKey(keyResponse.data.apiKey) + dataStore.edit { it[API_KEY] = keyResponse.data.apiKey } + dataStore.edit { it[USER_EMAIL] = email } + Result.success(Unit) + } else { + Result.failure(Exception(keyResponse.error?.message ?: "Failed to create API key")) + } + } else { + Result.failure(Exception(loginResponse.error?.message ?: "Login failed")) + } + } else { + Result.failure(Exception(response.error?.message ?: "Login failed")) + } + } + + suspend fun logout() { + api.clearApiKey() + dataStore.edit { prefs -> + prefs.remove(API_KEY) + prefs.remove(USER_EMAIL) + } + } + + suspend fun getDays(): Result> { + val response = api.getDays() + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to get days")) + } + + suspend fun getDay(date: String): Result { + val response = api.getDay(date) + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to get day")) + } + + suspend fun createEntry(date: String, type: String, content: String): Result { + val response = api.createEntry(date, type, content) + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to create entry")) + } + + suspend fun deleteEntry(id: String): Result { + val response = api.deleteEntry(id) + return if (response.data != null) Result.success(Unit) + else Result.failure(Exception(response.error?.message ?: "Failed to delete entry")) + } + + suspend fun generateJournal(date: String): Result { + val response = api.generateJournal(date) + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to generate journal")) + } + + suspend fun getJournal(date: String): Result { + val response = api.getJournal(date) + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to get journal")) + } + + suspend fun getSettings(): Result { + val response = api.getSettings() + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to get settings")) + } + + suspend fun updateSettings(settings: Settings): Result { + val response = api.updateSettings(settings) + return if (response.data != null) Result.success(response.data) + else Result.failure(Exception(response.error?.message ?: "Failed to update settings")) + } +} diff --git a/android/app/src/main/java/com/totalrecall/ui/Navigation.kt b/android/app/src/main/java/com/totalrecall/ui/Navigation.kt new file mode 100644 index 0000000..d6983b6 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/Navigation.kt @@ -0,0 +1,148 @@ +package com.totalrecall.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.totalrecall.ui.auth.AuthScreen +import com.totalrecall.ui.history.HistoryScreen +import com.totalrecall.ui.home.HomeScreen +import com.totalrecall.ui.journal.JournalScreen +import com.totalrecall.ui.settings.SettingsScreen +import com.totalrecall.viewmodel.MainViewModel +import kotlinx.coroutines.launch + +sealed class Screen(val route: String) { + object Auth : Screen("auth") + object Home : Screen("home") + object History : Screen("history") + object Day : Screen("day/{date}") { + fun createRoute(date: String) = "day/$date" + } + object Journal : Screen("journal/{date}") { + fun createRoute(date: String) = "journal/$date" + } + object Settings : Screen("settings") +} + +@Composable +fun AppNavigation( + viewModel: MainViewModel = viewModel() +) { + val navController = rememberNavController() + val isLoggedIn by viewModel.isLoggedIn.collectAsState() + val authState by viewModel.authState.collectAsState() + val days by viewModel.days.collectAsState() + val currentDay by viewModel.currentDay.collectAsState() + val journal by viewModel.journal.collectAsState() + val settings by viewModel.settings.collectAsState() + + val scope = rememberCoroutineScope() + val today = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(java.util.Date()) + + if (!isLoggedIn) { + AuthScreen( + authState = authState, + onLogin = { email, password -> viewModel.login(email, password) }, + onRegister = { email, password -> viewModel.register(email, password) } + ) + } else { + NavHost(navController = navController, startDestination = Screen.Home.route) { + composable(Screen.Home.route) { + HomeScreen( + dayResponse = currentDay, + onAddEntry = { date, type, content -> + viewModel.createEntry(date, type, content) + }, + onDeleteEntry = { id -> viewModel.deleteEntry(id, today) }, + onGenerateJournal = { viewModel.generateJournal(today) }, + onNavigateToJournal = { + viewModel.loadJournal(today) + navController.navigate(Screen.Journal.createRoute(today)) + }, + onNavigateToHistory = { + viewModel.loadDays() + navController.navigate(Screen.History.route) + }, + onNavigateToSettings = { + viewModel.loadSettings() + navController.navigate(Screen.Settings.route) + } + ) + } + + composable(Screen.History.route) { + HistoryScreen( + days = days, + onDayClick = { date -> + viewModel.loadDay(date) + navController.navigate(Screen.Day.createRoute(date)) + }, + onBack = { navController.popBackStack() } + ) + } + + composable( + route = Screen.Day.route, + arguments = listOf(navArgument("date") { type = NavType.StringType }) + ) { backStackEntry -> + val date = backStackEntry.arguments?.getString("date") ?: today + HomeScreen( + dayResponse = currentDay, + onAddEntry = { d, type, content -> viewModel.createEntry(d, type, content) }, + onDeleteEntry = { id -> viewModel.deleteEntry(id, date) }, + onGenerateJournal = { viewModel.generateJournal(date) }, + onNavigateToJournal = { + viewModel.loadJournal(date) + navController.navigate(Screen.Journal.createRoute(date)) + }, + onNavigateToHistory = { + viewModel.loadDays() + navController.navigate(Screen.History.route) + }, + onNavigateToSettings = { + viewModel.loadSettings() + navController.navigate(Screen.Settings.route) + } + ) + } + + composable( + route = Screen.Journal.route, + arguments = listOf(navArgument("date") { type = NavType.StringType }) + ) { backStackEntry -> + val date = backStackEntry.arguments?.getString("date") ?: today + JournalScreen( + journal = journal, + isGenerating = false, + onBack = { navController.popBackStack() }, + onRegenerate = { viewModel.generateJournal(date) } + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + settings = settings, + onSave = { newSettings -> viewModel.updateSettings(newSettings) }, + onLogout = { + viewModel.logout() + navController.navigate(Screen.Auth.route) { + popUpTo(0) { inclusive = true } + } + }, + onBack = { navController.popBackStack() } + ) + } + } + + androidx.compose.runtime.LaunchedEffect(Unit) { + viewModel.loadDay(today) + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/ui/Theme.kt b/android/app/src/main/java/com/totalrecall/ui/Theme.kt new file mode 100644 index 0000000..e42d4f5 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/Theme.kt @@ -0,0 +1,28 @@ +package com.totalrecall.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF6366F1), + secondary = Color(0xFF8B5CF6), + tertiary = Color(0xFFA855F7), + background = Color(0xFF020917), + surface = Color(0xFF0F172A), + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color(0xFFE2E8F0), + onSurface = Color(0xFFE2E8F0), +) + +@Composable +fun TotalRecallTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = DarkColorScheme, + content = content + ) +} diff --git a/android/app/src/main/java/com/totalrecall/ui/auth/AuthScreen.kt b/android/app/src/main/java/com/totalrecall/ui/auth/AuthScreen.kt new file mode 100644 index 0000000..cac0044 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/auth/AuthScreen.kt @@ -0,0 +1,127 @@ +package com.totalrecall.ui.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.totalrecall.viewmodel.UiState + +@Composable +fun AuthScreen( + authState: UiState, + onLogin: (String, String) -> Unit, + onRegister: (String, String) -> Unit +) { + var isLogin by remember { mutableStateOf(true) } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(authState) { + if (authState is UiState.Error) { + error = authState.message + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "TotalRecall", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = isLogin, + onClick = { isLogin = true }, + label = { Text("Login") }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = !isLogin, + onClick = { isLogin = false }, + label = { Text("Register") }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true + ) + + error?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + error = null + if (isLogin) onLogin(email, password) + else onRegister(email, password) + }, + modifier = Modifier.fillMaxWidth(), + enabled = authState !is UiState.Loading && email.isNotBlank() && password.length >= 8 + ) { + if (authState is UiState.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(if (isLogin) "Login" else "Create Account") + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/ui/history/HistoryScreen.kt b/android/app/src/main/java/com/totalrecall/ui/history/HistoryScreen.kt new file mode 100644 index 0000000..be69958 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/history/HistoryScreen.kt @@ -0,0 +1,110 @@ +package com.totalrecall.ui.history + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.totalrecall.api.DayInfo +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HistoryScreen( + days: List, + onDayClick: (String) -> Unit, + onBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("History") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + if (days.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center + ) { + Text( + text = "No entries yet.\nStart journaling!", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(days) { day -> + DayCard( + day = day, + onClick = { onDayClick(day.date) } + ) + } + } + } + } +} + +@Composable +fun DayCard( + day: DayInfo, + onClick: () -> Unit +) { + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) } + val displayFormat = remember { SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()) } + val displayDate = remember(day.date) { + try { + displayFormat.format(dateFormat.parse(day.date) ?: Date()) + } catch (e: Exception) { + day.date + } + } + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayDate, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${day.entryCount} ${if (day.entryCount == 1) "entry" else "entries"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (day.hasJournal) { + Icon( + Icons.Default.AutoStories, + contentDescription = "Has journal", + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp) + ) + } + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/ui/home/HomeScreen.kt b/android/app/src/main/java/com/totalrecall/ui/home/HomeScreen.kt new file mode 100644 index 0000000..bd48467 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/home/HomeScreen.kt @@ -0,0 +1,257 @@ +package com.totalrecall.ui.home + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.totalrecall.api.DayResponse +import com.totalrecall.api.Entry +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + dayResponse: DayResponse?, + onAddEntry: (String, String, String) -> Unit, + onDeleteEntry: (String) -> Unit, + onGenerateJournal: () -> Unit, + onNavigateToJournal: () -> Unit, + onNavigateToHistory: () -> Unit, + onNavigateToSettings: () -> Unit +) { + var entryType by remember { mutableStateOf("text") } + var entryContent by remember { mutableStateOf("") } + var showEntryTypes by remember { mutableStateOf(false) } + + val today = remember { + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date()) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Today") + Text( + text = SimpleDateFormat("EEEE, MMMM d", Locale.getDefault()).format(Date()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + IconButton(onClick = onNavigateToSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + } + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNavigateToHistory, + containerColor = MaterialTheme.colorScheme.secondary + ) { + Icon(Icons.Default.History, contentDescription = "History") + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + ) { + if (dayResponse?.journal != null) { + Button( + onClick = onNavigateToJournal, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary) + ) { + Icon(Icons.Default.AutoStories, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("View Journal") + } + Spacer(modifier = Modifier.height(16.dp)) + } else if ((dayResponse?.entries?.size ?: 0) > 0) { + OutlinedButton( + onClick = onGenerateJournal, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.AutoAwesome, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Generate Journal") + } + Spacer(modifier = Modifier.height(16.dp)) + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = entryType == "text", + onClick = { entryType = "text" }, + label = { Text("Text") }, + leadingIcon = { Icon(Icons.Default.TextFields, null, Modifier.size(18.dp)) } + ) + FilterChip( + selected = entryType == "voice", + onClick = { entryType = "voice" }, + label = { Text("Voice") }, + leadingIcon = { Icon(Icons.Default.Mic, null, Modifier.size(18.dp)) } + ) + FilterChip( + selected = entryType == "health", + onClick = { entryType = "health" }, + label = { Text("Health") }, + leadingIcon = { Icon(Icons.Default.Favorite, null, Modifier.size(18.dp)) } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = entryContent, + onValueChange = { entryContent = it }, + label = { Text("What's on your mind?") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4 + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { + onAddEntry(today, entryType, entryContent) + entryContent = "" + }, + modifier = Modifier.align(Alignment.End), + enabled = entryContent.isNotBlank() + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text("Add") + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Today's Entries (${dayResponse?.entries?.size ?: 0})", + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (dayResponse?.entries.isNullOrEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "No entries yet today.\nStart capturing your day!", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(dayResponse!!.entries) { entry -> + EntryCard( + entry = entry, + onDelete = { onDeleteEntry(entry.id) } + ) + } + } + } + } + } +} + +@Composable +fun EntryCard( + entry: Entry, + onDelete: () -> Unit +) { + val timeFormat = remember { SimpleDateFormat("h:mm a", Locale.getDefault()) } + val createdAt = remember(entry.createdAt) { + try { + timeFormat.format(SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()).parse(entry.createdAt) ?: Date()) + } catch (e: Exception) { + entry.createdAt + } + } + + val typeIcon = when (entry.type) { + "text" -> Icons.Default.TextFields + "voice" -> Icons.Default.Mic + "photo" -> Icons.Default.Photo + "health" -> Icons.Default.Favorite + else -> Icons.Default.Note + } + + val typeColor = when (entry.type) { + "text" -> MaterialTheme.colorScheme.primary + "voice" -> MaterialTheme.colorScheme.secondary + "photo" -> MaterialTheme.colorScheme.tertiary + "health" -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Icon( + typeIcon, + contentDescription = null, + tint = typeColor, + modifier = Modifier.padding(top = 4.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = createdAt, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = entry.content, + style = MaterialTheme.typography.bodyMedium + ) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/ui/journal/JournalScreen.kt b/android/app/src/main/java/com/totalrecall/ui/journal/JournalScreen.kt new file mode 100644 index 0000000..d034c5c --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/journal/JournalScreen.kt @@ -0,0 +1,131 @@ +package com.totalrecall.ui.journal + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.totalrecall.api.Journal +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JournalScreen( + journal: Journal?, + isGenerating: Boolean, + onBack: () -> Unit, + onRegenerate: () -> Unit +) { + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) } + val displayFormat = remember { SimpleDateFormat("EEEE, MMMM d, yyyy", Locale.getDefault()) } + val displayDate = remember(journal?.date ?: "") { + try { + displayFormat.format(dateFormat.parse(journal?.date ?: "") ?: Date()) + } catch (e: Exception) { + journal?.date ?: "" + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Journal") + Text( + text = displayDate, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + contentAlignment = Alignment.Center + ) { + when { + isGenerating -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text("Generating your journal...") + } + } + journal == null -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No journal generated yet", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRegenerate) { + Text("Generate Journal") + } + } + } + else -> { + Column(modifier = Modifier.fillMaxSize()) { + journal.generatedAt.let { generatedAt -> + val genFormat = remember { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) } + val timeFormat = remember { SimpleDateFormat("MMMM d, h:mm a", Locale.getDefault()) } + val time = try { + timeFormat.format(genFormat.parse(generatedAt) ?: Date()) + } catch (e: Exception) { + generatedAt + } + Text( + text = "Generated $time • ${journal.entryCount} entries", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + Card( + modifier = Modifier.fillMaxSize(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Text( + text = journal.content, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + style = MaterialTheme.typography.bodyLarge, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.5 + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = onRegenerate, + modifier = Modifier.align(Alignment.End) + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Regenerate") + } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/totalrecall/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..a010326 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/ui/settings/SettingsScreen.kt @@ -0,0 +1,237 @@ +package com.totalrecall.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.totalrecall.api.Settings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + settings: Settings?, + onSave: (Settings) -> Unit, + onLogout: () -> Unit, + onBack: () -> Unit +) { + var aiProvider by remember { mutableStateOf(settings?.aiProvider ?: "openai") } + var apiKey by remember { mutableStateOf(settings?.aiApiKey ?: "") } + var model by remember { mutableStateOf(settings?.aiModel ?: "gpt-4") } + var baseUrl by remember { mutableStateOf(settings?.aiBaseUrl ?: "") } + var journalPrompt by remember { mutableStateOf(settings?.journalPrompt ?: "You are a thoughtful journal writer.") } + var language by remember { mutableStateOf(settings?.language ?: "en") } + var saving by remember { mutableStateOf(false) } + + LaunchedEffect(settings) { + settings?.let { + aiProvider = it.aiProvider + apiKey = it.aiApiKey ?: "" + model = it.aiModel + baseUrl = it.aiBaseUrl ?: "" + journalPrompt = it.journalPrompt + language = it.language + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onLogout) { + Icon(Icons.Default.Logout, contentDescription = "Logout") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "AI Provider", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + + aiProvider.let { current -> + listOf("openai" to "OpenAI (GPT-4)", "anthropic" to "Anthropic (Claude)").forEach { (value, label) -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = aiProvider == value, + onClick = { aiProvider = value } + ) + Text(label) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (aiProvider in listOf("openai", "anthropic")) { + OutlinedTextField( + value = apiKey, + onValueChange = { apiKey = it }, + label = { Text("API Key") }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true + ) + } + + if (aiProvider in listOf("ollama", "lmstudio")) { + OutlinedTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + label = { Text("Base URL") }, + placeholder = { + Text( + if (aiProvider == "ollama") "http://localhost:11434" + else "http://localhost:1234/v1" + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = model, + onValueChange = { model = it }, + label = { Text("Model") }, + placeholder = { + Text( + when (aiProvider) { + "openai" -> "gpt-4" + "anthropic" -> "claude-3-sonnet-20240229" + "ollama" -> "llama3.2" + else -> "local-model" + } + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Journal Generation", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = journalPrompt, + onValueChange = { journalPrompt = it }, + label = { Text("System Prompt") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = language.let { lang -> + mapOf( + "en" to "English", + "de" to "Deutsch", + "es" to "Español", + "fr" to "Français" + )[lang] ?: lang + }, + onValueChange = {}, + readOnly = true, + label = { Text("Language") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + listOf("en" to "English", "de" to "Deutsch", "es" to "Español", "fr" to "Français").forEach { (code, name) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + language = code + expanded = false + } + ) + } + } + } + } + } + + Button( + onClick = { + saving = true + onSave( + Settings( + aiProvider = aiProvider, + aiApiKey = apiKey.ifBlank { null }, + aiModel = model, + aiBaseUrl = baseUrl.ifBlank { null }, + journalPrompt = journalPrompt, + language = language + ) + ) + }, + modifier = Modifier.fillMaxWidth(), + enabled = !saving + ) { + if (saving) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Save Settings") + } + } + } + } +} diff --git a/android/app/src/main/java/com/totalrecall/viewmodel/MainViewModel.kt b/android/app/src/main/java/com/totalrecall/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..2a024e7 --- /dev/null +++ b/android/app/src/main/java/com/totalrecall/viewmodel/MainViewModel.kt @@ -0,0 +1,137 @@ +package com.totalrecall.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.totalrecall.BuildConfig +import com.totalrecall.api.* +import com.totalrecall.repository.Repository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +sealed class UiState { + object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() +} + +class MainViewModel(application: Application) : AndroidViewModel(application) { + private val repository = Repository(application, BuildConfig.API_BASE_URL) + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn + + private val _authState = MutableStateFlow>(UiState.Loading) + val authState: StateFlow> = _authState + + private val _days = MutableStateFlow>(emptyList()) + val days: StateFlow> = _days + + private val _currentDay = MutableStateFlow(null) + val currentDay: StateFlow = _currentDay + + private val _journal = MutableStateFlow(null) + val journal: StateFlow = _journal + + private val _settings = MutableStateFlow(null) + val settings: StateFlow = _settings + + init { + checkLoginState() + } + + private fun checkLoginState() { + viewModelScope.launch { + repository.loadSavedState() + _isLoggedIn.value = repository.isLoggedIn() + _authState.value = UiState.Success(Unit) + } + } + + fun login(email: String, password: String) { + viewModelScope.launch { + _authState.value = UiState.Loading + repository.login(email, password).fold( + onSuccess = { + _isLoggedIn.value = true + _authState.value = UiState.Success(Unit) + }, + onFailure = { _authState.value = UiState.Error(it.message ?: "Login failed") } + ) + } + } + + fun register(email: String, password: String) { + viewModelScope.launch { + _authState.value = UiState.Loading + repository.register(email, password).fold( + onSuccess = { _authState.value = UiState.Success(Unit) }, + onFailure = { _authState.value = UiState.Error(it.message ?: "Registration failed") } + ) + } + } + + fun logout() { + viewModelScope.launch { + repository.logout() + _isLoggedIn.value = false + _days.value = emptyList() + _currentDay.value = null + _journal.value = null + } + } + + fun loadDays() { + viewModelScope.launch { + repository.getDays().onSuccess { _days.value = it } + } + } + + fun loadDay(date: String) { + viewModelScope.launch { + repository.getDay(date).onSuccess { _currentDay.value = it } + } + } + + fun createEntry(date: String, type: String, content: String) { + viewModelScope.launch { + repository.createEntry(date, type, content).onSuccess { + loadDay(date) + } + } + } + + fun deleteEntry(id: String, date: String) { + viewModelScope.launch { + repository.deleteEntry(id).onSuccess { loadDay(date) } + } + } + + fun generateJournal(date: String) { + viewModelScope.launch { + repository.generateJournal(date).onSuccess { + _journal.value = it + loadDay(date) + } + } + } + + fun loadJournal(date: String) { + viewModelScope.launch { + repository.getJournal(date).onSuccess { _journal.value = it } + } + } + + fun loadSettings() { + viewModelScope.launch { + repository.getSettings().onSuccess { _settings.value = it } + } + } + + fun updateSettings(settings: Settings) { + viewModelScope.launch { + repository.updateSettings(settings).onSuccess { _settings.value = it } + } + } +} diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca1931b --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bd5ef91 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + TotalRecall + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..02564f1 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..3a6ec8d --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.20" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..1215ec8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a363877 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..17d6a62 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/812c56a2fbbd51caf1865e37dbfb8e169137ab13/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..44130a3 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "TotalRecall" +include(":app") diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d1b1a1d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +# Database connection (SQLite, PostgreSQL, or MySQL) +DATABASE_URL="file:./data/totalrecall.db" + +# Media storage directory +MEDIA_DIR="./data/media" + +# JWT secret for authentication tokens (REQUIRED in production) +JWT_SECRET="change-this-to-a-random-string-in-production" + +# Server port +PORT="3000" + +# CORS origin (use specific domain in production) +CORS_ORIGIN="*" + +# Example PostgreSQL connection: +# DATABASE_URL="postgresql://postgres:password@db:5432/totalrecall" + +# Example MySQL connection: +# DATABASE_URL="mysql://root:password@localhost:3306/totalrecall" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f6f3aeb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,39 @@ +# Build stage +FROM oven/bun:1.1-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN bun install + +COPY prisma ./prisma +RUN bunx prisma generate + +COPY src ./src + +RUN bun build src/index.ts --outdir ./dist --target bun + +# Production stage +FROM oven/bun:1.1-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 bun + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +RUN mkdir -p /data && chown -R bun:nodejs /data + +USER bun + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 + +CMD ["bun", "run", "src/index.ts"] diff --git a/backend/bun.lock b/backend/bun.lock new file mode 100644 index 0000000..7855404 --- /dev/null +++ b/backend/bun.lock @@ -0,0 +1,50 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "totalrecall-backend", + "dependencies": { + "@prisma/client": "^5.22.0", + "bcryptjs": "^2.4.3", + "hono": "^4.6.10", + "jose": "^5.9.6", + "nanoid": "^5.0.8", + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "prisma": "^5.22.0", + "typescript": "^5.6.3", + }, + }, + }, + "packages": { + "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], + + "@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="], + + "@prisma/engines": ["@prisma/engines@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/fetch-engine": "5.22.0", "@prisma/get-platform": "5.22.0" } }, "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA=="], + + "@prisma/engines-version": ["@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "", {}, "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "@prisma/get-platform": "5.22.0" } }, "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA=="], + + "@prisma/get-platform": ["@prisma/get-platform@5.22.0", "", { "dependencies": { "@prisma/debug": "5.22.0" } }, "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q=="], + + "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], + + "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], + + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + + "nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="], + + "prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..351c46d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,26 @@ +{ + "name": "totalrecall-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "build": "bun build src/index.ts --outdir ./dist --target bun", + "start": "bun run src/index.ts", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^5.22.0", + "bcryptjs": "^2.4.3", + "hono": "^4.6.10", + "jose": "^5.9.6", + "nanoid": "^5.0.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "prisma": "^5.22.0", + "typescript": "^5.6.3" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..27f17d6 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,101 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + email String @unique + passwordHash String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + apiKeys ApiKey[] + entries Entry[] + journals Journal[] + tasks Task[] + settings Settings? +} + +model ApiKey { + id String @id @default(uuid()) + userId String + keyHash String @unique + name String + lastUsedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model Entry { + id String @id @default(uuid()) + userId String + date String + type String + content String + mediaPath String? + metadata String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, date]) + @@index([date]) +} + +model Journal { + id String @id @default(uuid()) + userId String + date String + content String + entryCount Int + generatedAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tasks Task[] + + @@unique([userId, date]) + @@index([userId]) +} + +model Task { + id String @id @default(uuid()) + userId String + journalId String + type String @default("journal_generate") + status String @default("pending") + provider String + model String? + prompt String + request String? + response String? + error String? + createdAt DateTime @default(now()) + completedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + journal Journal @relation(fields: [journalId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([journalId]) +} + +model Settings { + userId String @id + aiProvider String @default("openai") + aiApiKey String? + aiModel String @default("gpt-4") + aiBaseUrl String? + journalPrompt String @default("You are a thoughtful journal writer. Based on the entries provided, write a reflective journal entry for this day in a warm, personal tone.") + language String @default("en") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..0155745 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,588 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { env } from 'hono/adapter'; +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; +import { createHash, randomBytes } from 'crypto'; +import * as jose from 'jose'; +import { Prisma } from '@prisma/client'; + +const app = new Hono(); + +const envVars = env(app); + +app.use('*', logger()); +app.use('*', cors({ + origin: envVars.CORS_ORIGIN || '*', + credentials: true, +})); + +const prisma = new PrismaClient({ + datasourceUrl: envVars.DATABASE_URL || 'file:./data/totalrecall.db', +}); + +app.use('*', async (c, next) => { + c.set('prisma', prisma as any); + await next(); +}); + +const getUserId = async (c: any): Promise => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) return null; + + const apiKey = authHeader.slice(7); + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const keyRecord = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (!keyRecord) return null; + + await prisma.apiKey.update({ + where: { id: keyRecord.id }, + data: { lastUsedAt: new Date() }, + }); + + return keyRecord.userId; +}; + +app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + +// Auth routes +app.post('/api/v1/auth/register', async (c) => { + const { email, password } = await c.req.json(); + + if (!email || !password) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400); + } + + if (password.length < 8) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 8 characters' } }, 400); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid email format' } }, 400); + } + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return c.json({ data: null, error: { code: 'CONFLICT', message: 'Email already registered' } }, 409); + } + + const passwordHash = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { + email, + passwordHash, + settings: { create: {} }, + }, + select: { id: true, email: true, createdAt: true }, + }); + + return c.json({ data: { user }, error: null }, 201); +}); + +app.post('/api/v1/auth/login', async (c) => { + const { email, password } = await c.req.json(); + + if (!email || !password) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400); + } + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401); + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401); + } + + const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production'; + const token = await new jose.SignJWT({ userId: user.id }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('7d') + .sign(new TextEncoder().encode(jwtSecret)); + + return c.json({ data: { token, userId: user.id }, error: null }); +}); + +app.post('/api/v1/auth/api-key', async (c) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401); + } + + const token = authHeader.slice(7); + const jwtSecret = envVars.JWT_SECRET || 'development-secret-change-in-production'; + + let userId: string; + try { + const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret)); + userId = payload.userId as string; + } catch { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }, 401); + } + + const { name } = (await c.req.json()) || {}; + const apiKey = randomBytes(32).toString('hex'); + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const keyRecord = await prisma.apiKey.create({ + data: { userId, keyHash, name: name || 'Default' }, + }); + + return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201); +}); + +// Days routes +app.get('/api/v1/days', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const days = await prisma.entry.groupBy({ + by: ['date'], + where: { userId }, + _count: { id: true }, + orderBy: { date: 'desc' }, + }); + + const journals = await prisma.journal.findMany({ + where: { userId }, + select: { date: true, generatedAt: true }, + }); + + const journalMap = new Map(journals.map(j => [j.date, j])); + + const result = days.map(day => ({ + date: day.date, + entryCount: day._count.id, + hasJournal: journalMap.has(day.date), + journalGeneratedAt: journalMap.get(day.date)?.generatedAt, + })); + + return c.json({ data: result, error: null }); +}); + +app.get('/api/v1/days/:date', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { date } = c.req.param(); + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(date)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400); + } + + const [entries, journal] = await Promise.all([ + prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }), + prisma.journal.findFirst({ where: { userId, date } }), + ]); + + return c.json({ data: { date, entries, journal }, error: null }); +}); + +app.delete('/api/v1/days/:date', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { date } = c.req.param(); + + await prisma.$transaction([ + prisma.entry.deleteMany({ where: { userId, date } }), + prisma.journal.deleteMany({ where: { userId, date } }), + ]); + + return c.json({ data: { deleted: true }, error: null }); +}); + +// Entries routes +app.post('/api/v1/entries', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { date, type, content, metadata } = await c.req.json(); + + if (!date || !type || !content) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400); + } + + const validTypes = ['text', 'voice', 'photo', 'health', 'location']; + if (!validTypes.includes(type)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400); + } + + const entry = await prisma.entry.create({ + data: { userId, date, type, content, metadata: metadata ? JSON.stringify(metadata) : null }, + }); + + return c.json({ data: entry, error: null }, 201); +}); + +app.get('/api/v1/entries/:id', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { id } = c.req.param(); + const entry = await prisma.entry.findFirst({ where: { id, userId } }); + + if (!entry) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + return c.json({ data: entry, error: null }); +}); + +app.put('/api/v1/entries/:id', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { id } = c.req.param(); + const { content, metadata } = await c.req.json(); + + const existing = await prisma.entry.findFirst({ where: { id, userId } }); + if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + + const entry = await prisma.entry.update({ + where: { id }, + data: { + content: content ?? existing.content, + metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata, + }, + }); + + return c.json({ data: entry, error: null }); +}); + +app.delete('/api/v1/entries/:id', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { id } = c.req.param(); + const existing = await prisma.entry.findFirst({ where: { id, userId } }); + if (!existing) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + + await prisma.entry.delete({ where: { id } }); + return c.json({ data: { deleted: true }, error: null }); +}); + +// Journal routes +app.post('/api/v1/journal/generate/:date', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { date } = c.req.param(); + + const [entries, settings] = await Promise.all([ + prisma.entry.findMany({ where: { userId, date }, orderBy: { createdAt: 'asc' } }), + prisma.settings.findUnique({ where: { userId } }), + ]); + + if (entries.length === 0) { + return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400); + } + + const provider = settings?.aiProvider || 'openai'; + + if ((provider === 'openai' || provider === 'anthropic') && !settings?.aiApiKey) { + return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400); + } + + const entriesText = entries.map(entry => { + let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`; + if (entry.metadata) { + try { + const meta = JSON.parse(entry.metadata); + if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`; + if (meta.duration) text += `\nDuration: ${meta.duration}s`; + } catch {} + } + return text; + }).join('\n\n'); + + const systemPrompt = settings?.journalPrompt || 'You are a thoughtful journal writer.'; + const userPrompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry. + +ENTRIES: +${entriesText} + +JOURNAL:`; + + // Create placeholder journal and task + const placeholderJournal = await prisma.journal.create({ + data: { userId, date, content: 'Generating...', entryCount: entries.length }, + }); + + const task = await prisma.task.create({ + data: { + userId, + journalId: placeholderJournal.id, + type: 'journal_generate', + status: 'pending', + provider, + model: settings?.aiModel, + prompt: `${systemPrompt}\n\n${userPrompt}`, + }, + }); + + // Update journal with taskId + await prisma.journal.update({ + where: { id: placeholderJournal.id }, + data: { id: placeholderJournal.id }, + }); + + let requestBody: any = null; + let responseBody: any = null; + let content = ''; + + try { + if (provider === 'openai') { + requestBody = { + model: settings?.aiModel || 'gpt-4', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.7, + max_tokens: 2000, + }; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${settings?.aiApiKey}`, + }, + body: JSON.stringify(requestBody), + }); + + responseBody = await response.json(); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status} ${JSON.stringify(responseBody)}`); + } + + content = responseBody.choices?.[0]?.message?.content || ''; + + } else if (provider === 'anthropic') { + requestBody = { + model: settings?.aiModel || 'claude-3-sonnet-20240229', + max_tokens: 2000, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }; + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': settings?.aiApiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(requestBody), + }); + + responseBody = await response.json(); + + if (!response.ok) { + throw new Error(`Anthropic API error: ${response.status} ${JSON.stringify(responseBody)}`); + } + + content = responseBody.content?.[0]?.text || ''; + + } else if (provider === 'ollama') { + const baseUrl = settings?.aiBaseUrl || 'http://localhost:11434'; + requestBody = { + model: settings?.aiModel || 'llama3.2', + stream: false, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + }; + + const response = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + responseBody = await response.json(); + + if (!response.ok) { + throw new Error(`Ollama API error: ${response.status} ${JSON.stringify(responseBody)}`); + } + + content = responseBody.message?.content || ''; + + } else if (provider === 'lmstudio') { + const baseUrl = settings?.aiBaseUrl || 'http://localhost:1234/v1'; + requestBody = { + model: settings?.aiModel || 'local-model', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + temperature: 0.7, + max_tokens: 2000, + }; + + const response = await fetch(`${baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + responseBody = await response.json(); + + if (!response.ok) { + throw new Error(`LM Studio API error: ${response.status} ${JSON.stringify(responseBody)}`); + } + + content = responseBody.choices?.[0]?.message?.content || ''; + } + + if (!content) { + throw new Error('No content generated from AI'); + } + + // Update task with success + await prisma.task.update({ + where: { id: task.id }, + data: { + status: 'completed', + request: JSON.stringify(requestBody, null, 2), + response: JSON.stringify(responseBody, null, 2), + completedAt: new Date(), + }, + }); + + // Update journal with content + const journal = await prisma.journal.update({ + where: { id: placeholderJournal.id }, + data: { content, generatedAt: new Date() }, + }); + + return c.json({ data: { journal, task }, error: null }); + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + console.error('AI generation failed:', errorMessage); + + // Update task with error + await prisma.task.update({ + where: { id: task.id }, + data: { + status: 'failed', + error: errorMessage, + completedAt: new Date(), + }, + }); + + // Delete placeholder journal + await prisma.journal.delete({ where: { id: placeholderJournal.id } }); + + return c.json({ data: null, error: { code: 'AI_ERROR', message: `Failed to generate journal: ${errorMessage}` } }, 500); + } +}); + +app.get('/api/v1/journal/:date/tasks', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { date } = c.req.param(); + + const journal = await prisma.journal.findFirst({ where: { userId, date } }); + if (!journal) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'No journal found for this date' } }, 404); + } + + const tasks = await prisma.task.findMany({ + where: { journalId: journal.id }, + orderBy: { createdAt: 'desc' }, + }); + + return c.json({ data: tasks, error: null }); +}); + +app.get('/api/v1/tasks/:id', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { id } = c.req.param(); + + const task = await prisma.task.findFirst({ + where: { id, userId }, + }); + + if (!task) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404); + } + + return c.json({ data: task, error: null }); +}); + +app.get('/api/v1/journal/:date', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const { date } = c.req.param(); + const journal = await prisma.journal.findFirst({ where: { userId, date } }); + + if (!journal) return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'No journal found for this date' } }, 404); + return c.json({ data: journal, error: null }); +}); + +// Settings routes +app.get('/api/v1/settings', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + let settings = await prisma.settings.findUnique({ where: { userId } }); + if (!settings) { + settings = await prisma.settings.create({ data: { userId } }); + } + + return c.json({ data: settings, error: null }); +}); + +app.put('/api/v1/settings', async (c) => { + const userId = await getUserId(c); + if (!userId) return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + + const body = await c.req.json(); + const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body; + + const data: Record = {}; + if (aiProvider !== undefined) data.aiProvider = aiProvider; + if (aiApiKey !== undefined) data.aiApiKey = aiApiKey; + if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4'; + if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl; + if (journalPrompt !== undefined) data.journalPrompt = journalPrompt; + if (language !== undefined) data.language = language; + + const settings = await prisma.settings.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }); + + return c.json({ data: settings, error: null }); +}); + +app.notFound((c) => c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Not found' } }, 404)); +app.onError((err, c) => { + console.error('Unhandled error:', err); + return c.json({ data: null, error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500); +}); + +const port = parseInt(envVars.PORT || '3000', 10); +console.log(`Starting TotalRecall API on port ${port}`); + +export default { + port, + fetch: app.fetch, +}; diff --git a/backend/src/lib/types.ts b/backend/src/lib/types.ts new file mode 100644 index 0000000..1fe9b30 --- /dev/null +++ b/backend/src/lib/types.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from '@prisma/client'; + +export interface HonoEnv { + Variables: { + userId: string; + prisma: PrismaClient; + }; + Bindings: { + DATABASE_URL: string; + JWT_SECRET: string; + MEDIA_DIR: string; + PORT: string; + CORS_ORIGIN: string; + }; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..e531737 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,39 @@ +import { Context, Next } from 'hono'; +import { HonoEnv } from '../lib/types'; +import { createHash } from 'crypto'; + +export async function authMiddleware(c: Context, next: Next) { + const authHeader = c.req.header('Authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401); + } + + const apiKey = authHeader.slice(7); + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const prisma = c.get('prisma'); + + const keyRecord = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (!keyRecord) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid API key' } }, 401); + } + + await prisma.apiKey.update({ + where: { id: keyRecord.id }, + data: { lastUsedAt: new Date() }, + }); + + c.set('userId', keyRecord.userId); + await next(); +} + +export function authenticate() { + return async (c: Context, next: Next) => { + await authMiddleware(c, next); + }; +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..f660404 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,115 @@ +import { Hono } from 'hono'; +import { HonoEnv } from '../lib/types'; +import bcrypt from 'bcryptjs'; +import { createHash, randomBytes } from 'crypto'; +import * as jose from 'jose'; + +function createAuthRoutes() { + const app = new Hono(); + +authRoutes.post('/register', async (c) => { + const { email, password } = await c.req.json(); + + if (!email || !password) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400); + } + + if (password.length < 8) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Password must be at least 8 characters' } }, 400); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid email format' } }, 400); + } + + const prisma = c.get('prisma'); + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return c.json({ data: null, error: { code: 'CONFLICT', message: 'Email already registered' } }, 409); + } + + const passwordHash = await bcrypt.hash(password, 12); + + const user = await prisma.user.create({ + data: { + email, + passwordHash, + settings: { + create: {}, + }, + }, + select: { + id: true, + email: true, + createdAt: true, + }, + }); + + return c.json({ data: { user }, error: null }, 201); +}); + +authRoutes.post('/login', async (c) => { + const { email, password } = await c.req.json(); + + if (!email || !password) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Email and password are required' } }, 400); + } + + const prisma = c.get('prisma'); + const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production'; + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401); + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }, 401); + } + + const token = await new jose.SignJWT({ userId: user.id }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('7d') + .sign(new TextEncoder().encode(jwtSecret)); + + return c.json({ data: { token, userId: user.id }, error: null }); +}); + +authRoutes.post('/api-key', async (c) => { + const authHeader = c.req.header('Authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' } }, 401); + } + + const token = authHeader.slice(7); + const jwtSecret = c.env.JWT_SECRET || 'development-secret-change-in-production'; + + let userId: string; + try { + const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(jwtSecret)); + userId = payload.userId as string; + } catch { + return c.json({ data: null, error: { code: 'UNAUTHORIZED', message: 'Invalid or expired token' } }, 401); + } + + const { name } = await c.req.json(); + const apiKey = randomBytes(32).toString('hex'); + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const prisma = c.get('prisma'); + + const keyRecord = await prisma.apiKey.create({ + data: { + userId, + keyHash, + name: name || 'Default', + }, + }); + + return c.json({ data: { apiKey, id: keyRecord.id, name: keyRecord.name }, error: null }, 201); +}); diff --git a/backend/src/routes/days.ts b/backend/src/routes/days.ts new file mode 100644 index 0000000..c12f3fb --- /dev/null +++ b/backend/src/routes/days.ts @@ -0,0 +1,68 @@ +import { Hono } from 'hono'; +import { HonoEnv } from '../lib/types'; + +export const daysRoutes = new Hono(); + +daysRoutes.get('/', async (c) => { + const userId = c.get('userId'); + const prisma = c.get('prisma'); + + const days = await prisma.entry.groupBy({ + by: ['date'], + where: { userId }, + _count: { id: true }, + orderBy: { date: 'desc' }, + }); + + const journals = await prisma.journal.findMany({ + where: { userId }, + select: { date: true, generatedAt: true, entryCount: true }, + }); + + const journalMap = new Map(journals.map(j => [j.date, j])); + + const result = days.map(day => ({ + date: day.date, + entryCount: day._count.id, + hasJournal: journalMap.has(day.date), + journalGeneratedAt: journalMap.get(day.date)?.generatedAt, + })); + + return c.json({ data: result, error: null }); +}); + +daysRoutes.get('/:date', async (c) => { + const userId = c.get('userId'); + const { date } = c.req.param(); + const prisma = c.get('prisma'); + + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(date)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'Invalid date format. Use YYYY-MM-DD' } }, 400); + } + + const [entries, journal] = await Promise.all([ + prisma.entry.findMany({ + where: { userId, date }, + orderBy: { createdAt: 'asc' }, + }), + prisma.journal.findUnique({ + where: { userId_date: { userId, date } }, + }), + ]); + + return c.json({ data: { date, entries, journal }, error: null }); +}); + +daysRoutes.delete('/:date', async (c) => { + const userId = c.get('userId'); + const { date } = c.req.param(); + const prisma = c.get('prisma'); + + await prisma.$transaction([ + prisma.entry.deleteMany({ where: { userId, date } }), + prisma.journal.deleteMany({ where: { userId, date } }), + ]); + + return c.json({ data: { deleted: true }, error: null }); +}); diff --git a/backend/src/routes/entries.ts b/backend/src/routes/entries.ts new file mode 100644 index 0000000..66e139a --- /dev/null +++ b/backend/src/routes/entries.ts @@ -0,0 +1,166 @@ +import { Hono } from 'hono'; +import { HonoEnv } from '../lib/types'; + +export const entriesRoutes = new Hono(); + +entriesRoutes.post('/', async (c) => { + const userId = c.get('userId'); + const prisma = c.get('prisma'); + const mediaDir = c.env.MEDIA_DIR || './data/media'; + + const body = await c.req.json(); + const { date, type, content, metadata } = body; + + if (!date || !type || !content) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'date, type, and content are required' } }, 400); + } + + const validTypes = ['text', 'voice', 'photo', 'health', 'location']; + if (!validTypes.includes(type)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: `type must be one of: ${validTypes.join(', ')}` } }, 400); + } + + const entry = await prisma.entry.create({ + data: { + userId, + date, + type, + content, + metadata: metadata ? JSON.stringify(metadata) : null, + }, + }); + + return c.json({ data: entry, error: null }, 201); +}); + +entriesRoutes.get('/:id', async (c) => { + const userId = c.get('userId'); + const { id } = c.req.param(); + const prisma = c.get('prisma'); + + const entry = await prisma.entry.findFirst({ + where: { id, userId }, + }); + + if (!entry) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + } + + return c.json({ data: entry, error: null }); +}); + +entriesRoutes.put('/:id', async (c) => { + const userId = c.get('userId'); + const { id } = c.req.param(); + const prisma = c.get('prisma'); + + const body = await c.req.json(); + const { content, metadata } = body; + + const existing = await prisma.entry.findFirst({ + where: { id, userId }, + }); + + if (!existing) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + } + + const entry = await prisma.entry.update({ + where: { id }, + data: { + content: content ?? existing.content, + metadata: metadata !== undefined ? JSON.stringify(metadata) : existing.metadata, + }, + }); + + return c.json({ data: entry, error: null }); +}); + +entriesRoutes.delete('/:id', async (c) => { + const userId = c.get('userId'); + const { id } = c.req.param(); + const prisma = c.get('prisma'); + + const existing = await prisma.entry.findFirst({ + where: { id, userId }, + }); + + if (!existing) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + } + + await prisma.entry.delete({ where: { id } }); + + return c.json({ data: { deleted: true }, error: null }); +}); + +entriesRoutes.post('/:id/photo', async (c) => { + const userId = c.get('userId'); + const { id } = c.req.param(); + const prisma = c.get('prisma'); + const mediaDir = c.env.MEDIA_DIR || './data/media'; + + const entry = await prisma.entry.findFirst({ + where: { id, userId }, + }); + + if (!entry) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + } + + const body = await c.req.parseBody(); + const file = body.file; + + if (!file || !(file instanceof File)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'No file provided' } }, 400); + } + + const ext = file.name.split('.').pop() || 'jpg'; + const fileName = `${id}.${ext}`; + const userMediaDir = `${mediaDir}/${userId}/${entry.date}`; + const filePath = `${userMediaDir}/${fileName}`; + + await Bun.write(filePath, file); + + await prisma.entry.update({ + where: { id }, + data: { mediaPath: filePath }, + }); + + return c.json({ data: { mediaPath: filePath }, error: null }, 201); +}); + +entriesRoutes.post('/:id/voice', async (c) => { + const userId = c.get('userId'); + const { id } = c.req.param(); + const prisma = c.get('prisma'); + const mediaDir = c.env.MEDIA_DIR || './data/media'; + + const entry = await prisma.entry.findFirst({ + where: { id, userId }, + }); + + if (!entry) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'Entry not found' } }, 404); + } + + const body = await c.req.parseBody(); + const file = body.file; + + if (!file || !(file instanceof File)) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'No audio file provided' } }, 400); + } + + const fileName = `${id}.webm`; + const userMediaDir = `${mediaDir}/${userId}/${entry.date}`; + const filePath = `${userMediaDir}/${fileName}`; + + await Bun.write(filePath, file); + + await prisma.entry.update({ + where: { id }, + data: { mediaPath: filePath }, + }); + + return c.json({ data: { mediaPath: filePath }, error: null }, 201); +}); diff --git a/backend/src/routes/journal.ts b/backend/src/routes/journal.ts new file mode 100644 index 0000000..2b462ea --- /dev/null +++ b/backend/src/routes/journal.ts @@ -0,0 +1,112 @@ +import { Hono } from 'hono'; +import { HonoEnv } from '../lib/types'; +import { AIProvider, createAIProvider } from '../services/ai/provider'; + +export const journalRoutes = new Hono(); + +journalRoutes.post('/generate/:date', async (c) => { + const userId = c.get('userId'); + const { date } = c.req.param(); + const prisma = c.get('prisma'); + + const [entries, settings] = await Promise.all([ + prisma.entry.findMany({ + where: { userId, date }, + orderBy: { createdAt: 'asc' }, + }), + prisma.settings.findUnique({ + where: { userId }, + }), + ]); + + if (entries.length === 0) { + return c.json({ data: null, error: { code: 'NO_ENTRIES', message: 'No entries found for this date' } }, 400); + } + + if (!settings?.aiApiKey) { + return c.json({ data: null, error: { code: 'NO_AI_CONFIG', message: 'AI not configured. Please set up your API key in settings.' } }, 400); + } + + const provider = createAIProvider({ + provider: settings.aiProvider as AIProvider['provider'], + apiKey: settings.aiApiKey, + model: settings.aiModel, + baseUrl: settings.aiBaseUrl, + }); + + const entriesText = entries.map(entry => { + let text = `[${entry.type.toUpperCase()}] ${entry.createdAt.toISOString()}\n${entry.content}`; + if (entry.metadata) { + try { + const meta = JSON.parse(entry.metadata); + if (meta.location) text += `\nLocation: ${meta.location.lat}, ${meta.location.lng}`; + if (meta.duration) text += `\nDuration: ${meta.duration}s`; + } catch {} + } + return text; + }).join('\n\n'); + + const prompt = `The following entries were captured throughout the day (${date}). Write a thoughtful, reflective journal entry that: +1. Summarizes the key moments and activities +2. Reflects on any patterns, feelings, or insights +3. Ends with a forward-looking thought + +Use a warm, personal tone. The journal should flow naturally as prose. + +ENTRIES: +${entriesText} + +JOURNAL:`; + + try { + const content = await provider.generate(prompt, settings.journalPrompt); + + const journal = await prisma.journal.upsert({ + where: { userId_date: { userId, date } }, + create: { + userId, + date, + content, + entryCount: entries.length, + }, + update: { + content, + entryCount: entries.length, + generatedAt: new Date(), + }, + }); + + return c.json({ data: journal, error: null }); + } catch (err) { + console.error('AI generation failed:', err); + return c.json({ data: null, error: { code: 'AI_ERROR', message: 'Failed to generate journal. Check your AI configuration.' } }, 500); + } +}); + +journalRoutes.get('/:date', async (c) => { + const userId = c.get('userId'); + const { date } = c.req.param(); + const prisma = c.get('prisma'); + + const journal = await prisma.journal.findUnique({ + where: { userId_date: { userId, date } }, + }); + + if (!journal) { + return c.json({ data: null, error: { code: 'NOT_FOUND', message: 'No journal found for this date' } }, 404); + } + + return c.json({ data: journal, error: null }); +}); + +journalRoutes.delete('/:date', async (c) => { + const userId = c.get('userId'); + const { date } = c.req.param(); + const prisma = c.get('prisma'); + + await prisma.journal.deleteMany({ + where: { userId, date }, + }); + + return c.json({ data: { deleted: true }, error: null }); +}); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..57c00b6 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,69 @@ +import { Hono } from 'hono'; +import { HonoEnv } from '../lib/types'; + +export const settingsRoutes = new Hono(); + +settingsRoutes.get('/', async (c) => { + const userId = c.get('userId'); + const prisma = c.get('prisma'); + + const settings = await prisma.settings.findUnique({ + where: { userId }, + }); + + if (!settings) { + const newSettings = await prisma.settings.create({ + data: { userId }, + }); + return c.json({ data: newSettings, error: null }); + } + + return c.json({ data: settings, error: null }); +}); + +settingsRoutes.put('/', async (c) => { + const userId = c.get('userId'); + const prisma = c.get('prisma'); + + const body = await c.req.json(); + const { aiProvider, aiApiKey, aiModel, aiBaseUrl, journalPrompt, language } = body; + + const data: Record = {}; + if (aiProvider !== undefined) data.aiProvider = aiProvider; + if (aiApiKey !== undefined) data.aiApiKey = aiApiKey; + if (aiModel !== undefined) data.aiModel = aiModel || 'gpt-4'; + if (aiBaseUrl !== undefined) data.aiBaseUrl = aiBaseUrl; + if (journalPrompt !== undefined) data.journalPrompt = journalPrompt; + if (language !== undefined) data.language = language; + + const settings = await prisma.settings.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }); + + return c.json({ data: settings, error: null }); +}); + +settingsRoutes.post('/validate-key', async (c) => { + const body = await c.req.json(); + const { provider, apiKey, baseUrl } = body; + + if (!provider || !apiKey) { + return c.json({ data: null, error: { code: 'VALIDATION_ERROR', message: 'provider and apiKey are required' } }, 400); + } + + const { createAIProvider } = await import('../services/ai/provider'); + + try { + const aiProvider = createAIProvider({ + provider, + apiKey, + baseUrl, + }); + const valid = await aiProvider.validate?.(); + return c.json({ data: { valid: true }, error: null }); + } catch { + return c.json({ data: { valid: false }, error: null }); + } +}); diff --git a/backend/src/services/ai/anthropic.ts b/backend/src/services/ai/anthropic.ts new file mode 100644 index 0000000..5af6a0e --- /dev/null +++ b/backend/src/services/ai/anthropic.ts @@ -0,0 +1,64 @@ +import type { AIProvider, AIProviderConfig } from './provider'; + +export class AnthropicProvider implements AIProvider { + provider = 'anthropic' as const; + private apiKey: string; + private model: string; + private baseUrl: string; + + constructor(config: AIProviderConfig) { + this.apiKey = config.apiKey; + this.model = config.model || 'claude-3-sonnet-20240229'; + this.baseUrl = config.baseUrl || 'https://api.anthropic.com/v1'; + } + + async generate(prompt: string, systemPrompt?: string): Promise { + const response = await fetch(`${this.baseUrl}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify({ + model: this.model, + max_tokens: 2000, + system: systemPrompt, + messages: [ + { role: 'user', content: prompt } + ], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Anthropic API error: ${response.status} ${error}`); + } + + const data = await response.json() as { content: Array<{ text: string }> }; + return data.content[0]?.text || ''; + } + + async validate(): Promise { + try { + const response = await fetch(`${this.baseUrl}/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify({ + model: this.model, + max_tokens: 1, + messages: [{ role: 'user', content: 'hi' }], + }), + }); + return response.ok; + } catch { + return false; + } + } +} diff --git a/backend/src/services/ai/lmstudio.ts b/backend/src/services/ai/lmstudio.ts new file mode 100644 index 0000000..8272ea7 --- /dev/null +++ b/backend/src/services/ai/lmstudio.ts @@ -0,0 +1,52 @@ +import type { AIProvider, AIProviderConfig } from './provider'; + +export class LMStudioProvider implements AIProvider { + provider = 'lmstudio' as const; + private baseUrl: string; + private model: string; + + constructor(config: AIProviderConfig) { + this.baseUrl = config.baseUrl || 'http://localhost:1234/v1'; + this.model = config.model || 'local-model'; + } + + async generate(prompt: string, systemPrompt?: string): Promise { + const messages: Array<{ role: string; content: string }> = []; + + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + + messages.push({ role: 'user', content: prompt }); + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: this.model, + messages, + temperature: 0.7, + max_tokens: 2000, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`LM Studio API error: ${response.status} ${error}`); + } + + const data = await response.json() as { choices: Array<{ message: { content: string } }> }; + return data.choices[0]?.message?.content || ''; + } + + async validate(): Promise { + try { + const response = await fetch(`${this.baseUrl}/models`); + return response.ok; + } catch { + return false; + } + } +} diff --git a/backend/src/services/ai/ollama.ts b/backend/src/services/ai/ollama.ts new file mode 100644 index 0000000..c8033b9 --- /dev/null +++ b/backend/src/services/ai/ollama.ts @@ -0,0 +1,46 @@ +import type { AIProvider, AIProviderConfig } from './provider'; + +export class OllamaProvider implements AIProvider { + provider = 'ollama' as const; + private baseUrl: string; + private model: string; + + constructor(config: AIProviderConfig) { + this.baseUrl = config.baseUrl || 'http://localhost:11434'; + this.model = config.model || 'llama3.2'; + } + + async generate(prompt: string, systemPrompt?: string): Promise { + const response = await fetch(`${this.baseUrl}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: this.model, + stream: false, + messages: [ + ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), + { role: 'user', content: prompt }, + ], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Ollama API error: ${response.status} ${error}`); + } + + const data = await response.json() as { message: { content: string } }; + return data.message?.content || ''; + } + + async validate(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/tags`); + return response.ok; + } catch { + return false; + } + } +} diff --git a/backend/src/services/ai/openai.ts b/backend/src/services/ai/openai.ts new file mode 100644 index 0000000..79c0619 --- /dev/null +++ b/backend/src/services/ai/openai.ts @@ -0,0 +1,59 @@ +import type { AIProvider, AIProviderConfig } from './provider'; + +export class OpenAIProvider implements AIProvider { + provider = 'openai' as const; + private apiKey: string; + private model: string; + private baseUrl: string; + + constructor(config: AIProviderConfig) { + this.apiKey = config.apiKey; + this.model = config.model || 'gpt-4'; + this.baseUrl = config.baseUrl || 'https://api.openai.com/v1'; + } + + async generate(prompt: string, systemPrompt?: string): Promise { + const messages: Array<{ role: string; content: string }> = []; + + if (systemPrompt) { + messages.push({ role: 'system', content: systemPrompt }); + } + + messages.push({ role: 'user', content: prompt }); + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages, + temperature: 0.7, + max_tokens: 2000, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} ${error}`); + } + + const data = await response.json() as { choices: Array<{ message: { content: string } }> }; + return data.choices[0]?.message?.content || ''; + } + + async validate(): Promise { + try { + const response = await fetch(`${this.baseUrl}/models`, { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + }); + return response.ok; + } catch { + return false; + } + } +} diff --git a/backend/src/services/ai/provider.ts b/backend/src/services/ai/provider.ts new file mode 100644 index 0000000..c4bc2f2 --- /dev/null +++ b/backend/src/services/ai/provider.ts @@ -0,0 +1,32 @@ +export interface AIProvider { + provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; + generate(prompt: string, systemPrompt?: string): Promise; + validate?(): Promise; +} + +export interface AIProviderConfig { + provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; + apiKey: string; + model?: string; + baseUrl?: string; +} + +import { OpenAIProvider } from './openai'; +import { AnthropicProvider } from './anthropic'; +import { OllamaProvider } from './ollama'; +import { LMStudioProvider } from './lmstudio'; + +export function createAIProvider(config: AIProviderConfig): AIProvider { + switch (config.provider) { + case 'openai': + return new OpenAIProvider(config); + case 'anthropic': + return new AnthropicProvider(config); + case 'ollama': + return new OllamaProvider(config); + case 'lmstudio': + return new LMStudioProvider(config); + default: + throw new Error(`Unknown AI provider: ${config.provider}`); + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..a29e6ea --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["@types/bun"] + }, + "include": ["src/**/*", "prisma/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.prod.yml.old b/docker-compose.prod.yml.old new file mode 100644 index 0000000..8c49d25 --- /dev/null +++ b/docker-compose.prod.yml.old @@ -0,0 +1,30 @@ +version: '3.8' + +services: + backend: + image: ghcr.io/totalrecall/totalrecall:latest + ports: + - "3000:3000" + environment: + - DATABASE_URL=file:/data/totalrecall.db + - MEDIA_DIR=/data/media + - JWT_SECRET=${JWT_SECRET} + - PORT=3000 + - CORS_ORIGIN=https://your-domain.com + volumes: + - ./data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Uncomment to use with a reverse proxy like Traefik + # frontend: + # image: ghcr.io/totalrecall/frontend:latest + # labels: + # - "traefik.enable=true" + # - "traefik.http.routers.totalrecall.rule=Host(`your-domain.com`)" + # - "traefik.http.routers.totalrecall.entrypoints=websecure" + # - "traefik.http.routers.totalrecall.tls=true" diff --git a/docker-compose.yml.old b/docker-compose.yml.old new file mode 100644 index 0000000..3ca6aec --- /dev/null +++ b/docker-compose.yml.old @@ -0,0 +1,24 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + - "5173:80" + environment: + - DATABASE_URL=file:/data/totalrecall.db + - MEDIA_DIR=/data/media + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + - PORT=3000 + - CORS_ORIGIN=${CORS_ORIGIN:-*} + volumes: + - ./data:/data + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7c9f194 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm install + +COPY frontend ./ +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c54e796 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + TotalRecall - AI Journal + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1481ada --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..c56185e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2808 @@ +{ + "name": "totalrecall-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "totalrecall-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^6.0.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b04f130 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "totalrecall-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^6.0.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..df68a0f --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "TotalRecall", + "short_name": "TotalRecall", + "description": "AI-powered daily journal that captures life through multiple input methods", + "start_url": "/", + "display": "standalone", + "background_color": "#020617", + "theme_color": "#1e293b", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ccc0888 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,94 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { api } from './lib/api'; +import Auth from './pages/Auth'; +import Home from './pages/Home'; +import History from './pages/History'; +import Day from './pages/Day'; +import Journal from './pages/Journal'; +import Settings from './pages/Settings'; + +function PrivateRoute({ children }: { children: React.ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + const key = api.getApiKey(); + setIsAuthenticated(!!key); + }, []); + + if (isAuthenticated === null) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const key = api.getApiKey(); + setIsAuthenticated(!!key); + setLoading(false); + }, []); + + const handleAuth = () => { + setIsAuthenticated(true); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( + +
+ {isAuthenticated && ( + + )} + + : + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + } /> + +
+
+ ); +} + +export default App; diff --git a/frontend/src/components/EntryInput.tsx b/frontend/src/components/EntryInput.tsx new file mode 100644 index 0000000..7e14146 --- /dev/null +++ b/frontend/src/components/EntryInput.tsx @@ -0,0 +1,197 @@ +import { useState, useRef } from 'react'; + +interface Props { + onSubmit: (type: string, content: string, metadata?: object) => Promise<{ error: { message: string } | null }>; +} + +export default function EntryInput({ onSubmit }: Props) { + const [type, setType] = useState('text'); + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const fileInputRef = useRef(null); + const [recording, setRecording] = useState(false); + const [mediaRecorder, setMediaRecorder] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!content.trim()) return; + + setLoading(true); + setError(''); + + const res = await onSubmit(type, content); + if (res.error) { + setError(res.error.message); + } else { + setContent(''); + } + setLoading(false); + }; + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + const chunks: Blob[] = []; + + recorder.ondataavailable = (e) => chunks.push(e.data); + recorder.onstop = async () => { + const blob = new Blob(chunks, { type: 'audio/webm' }); + setLoading(true); + const res = await onSubmit('voice', 'Voice memo', { duration: Math.round(blob.size / 1000) }); + setLoading(false); + if (!res.error) setContent(''); + stream.getTracks().forEach(t => t.stop()); + }; + + recorder.start(); + setMediaRecorder(recorder); + setRecording(true); + } catch (err) { + setError('Microphone access denied'); + } + }; + + const stopRecording = () => { + if (mediaRecorder) { + mediaRecorder.stop(); + setRecording(false); + } + }; + + const handlePhotoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setLoading(true); + const res = await onSubmit('photo', `Photo: ${file.name}`); + setLoading(false); + if (!res.error) setContent(''); + e.target.value = ''; + }; + + return ( +
+
+ + + + +
+ + {type === 'text' && ( +
+ setContent(e.target.value)} + placeholder="What's on your mind?" + className="flex-1 px-4 py-3 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + autoFocus + /> + +
+ )} + + {type === 'photo' && ( +
+ + +
+ )} + + {type === 'voice' && ( +
+ {recording ? ( +
+
+ Recording... + +
+ ) : ( + + )} +
+ )} + + {type === 'health' && ( +
+ setContent(e.target.value)} + placeholder="How are you feeling? (e.g., 'Good energy, slight headache')" + className="w-full px-4 py-3 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + /> + +
+ )} + + {error &&

{error}

} + + ); +} diff --git a/frontend/src/components/EntryList.tsx b/frontend/src/components/EntryList.tsx new file mode 100644 index 0000000..dc50189 --- /dev/null +++ b/frontend/src/components/EntryList.tsx @@ -0,0 +1,77 @@ +import type { Entry } from '../lib/api'; + +interface Props { + entries: Entry[]; + onDelete: (id: string) => void; +} + +export default function EntryList({ entries, onDelete }: Props) { + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + }; + + const getTypeIcon = (type: string) => { + switch (type) { + case 'text': return '📝'; + case 'photo': return '📷'; + case 'voice': return '🎤'; + case 'health': return '❤️'; + case 'location': return '📍'; + default: return '📌'; + } + }; + + const getTypeColor = (type: string) => { + switch (type) { + case 'text': return 'border-l-blue-500'; + case 'photo': return 'border-l-yellow-500'; + case 'voice': return 'border-l-purple-500'; + case 'health': return 'border-l-red-500'; + case 'location': return 'border-l-green-500'; + default: return 'border-l-slate-500'; + } + }; + + return ( +
+ {entries.map((entry) => ( +
+
+
+
+ {getTypeIcon(entry.type)} + {formatTime(entry.createdAt)} +
+

{entry.content}

+ {entry.metadata && ( +
+ {(() => { + try { + const meta = JSON.parse(entry.metadata); + return meta.location ? ( + 📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)} + ) : meta.duration ? ( + ⏱️ {meta.duration}s + ) : null; + } catch { + return null; + } + })()} +
+ )} +
+ +
+
+ ))} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b205a63 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-slate-950 text-slate-100; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..f083fb2 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,172 @@ +const API_BASE = '/api/v1'; + +interface ApiResponse { + data: T | null; + error: { code: string; message: string } | null; +} + +class ApiClient { + private apiKey: string | null = null; + + setApiKey(key: string) { + this.apiKey = key; + localStorage.setItem('apiKey', key); + } + + getApiKey(): string | null { + if (this.apiKey) return this.apiKey; + this.apiKey = localStorage.getItem('apiKey'); + return this.apiKey; + } + + clearApiKey() { + this.apiKey = null; + localStorage.removeItem('apiKey'); + } + + private async request( + method: string, + path: string, + body?: unknown + ): Promise> { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (this.getApiKey()) { + headers['Authorization'] = `Bearer ${this.getApiKey()}`; + } + + const response = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + return response.json() as Promise>; + } + + async register(email: string, password: string) { + return this.request<{ user: { id: string; email: string } }>('POST', '/auth/register', { email, password }); + } + + async login(email: string, password: string) { + return this.request<{ token: string; userId: string }>('POST', '/auth/login', { email, password }); + } + + async createApiKey(name: string, token: string) { + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + const response = await fetch(`${API_BASE}/auth/api-key`, { + method: 'POST', + headers, + body: JSON.stringify({ name }), + }); + return response.json() as Promise>; + } + + async getDays() { + return this.request>('GET', '/days'); + } + + async getDay(date: string) { + return this.request<{ date: string; entries: Entry[]; journal: Journal | null }>('GET', `/days/${date}`); + } + + async deleteDay(date: string) { + return this.request<{ deleted: boolean }>('DELETE', `/days/${date}`); + } + + async createEntry(date: string, type: string, content: string, metadata?: object) { + return this.request('POST', '/entries', { date, type, content, metadata }); + } + + async updateEntry(id: string, content: string, metadata?: object) { + return this.request('PUT', `/entries/${id}`, { content, metadata }); + } + + async deleteEntry(id: string) { + return this.request<{ deleted: boolean }>('DELETE', `/entries/${id}`); + } + + async uploadPhoto(entryId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + const headers: Record = {}; + if (this.getApiKey()) { + headers['Authorization'] = `Bearer ${this.getApiKey()}`; + } + + const response = await fetch(`${API_BASE}/entries/${entryId}/photo`, { + method: 'POST', + headers, + body: formData, + }); + return response.json() as Promise>; + } + + async uploadVoice(entryId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + const headers: Record = {}; + if (this.getApiKey()) { + headers['Authorization'] = `Bearer ${this.getApiKey()}`; + } + + const response = await fetch(`${API_BASE}/entries/${entryId}/voice`, { + method: 'POST', + headers, + body: formData, + }); + return response.json() as Promise>; + } + + async generateJournal(date: string) { + return this.request('POST', `/journal/generate/${date}`); + } + + async getJournal(date: string) { + return this.request('GET', `/journal/${date}`); + } + + async getSettings() { + return this.request('GET', '/settings'); + } + + async updateSettings(settings: Partial) { + return this.request('PUT', '/settings', settings); + } +} + +export interface Entry { + id: string; + date: string; + type: 'text' | 'voice' | 'photo' | 'health' | 'location'; + content: string; + mediaPath?: string; + metadata?: string; + createdAt: string; +} + +export interface Journal { + id: string; + date: string; + content: string; + entryCount: number; + generatedAt: string; +} + +export interface Settings { + aiProvider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio'; + aiApiKey?: string; + aiModel: string; + aiBaseUrl?: string; + journalPrompt: string; + language: string; +} + +export const api = new ApiClient(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..9aa52ff --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx new file mode 100644 index 0000000..4a990b4 --- /dev/null +++ b/frontend/src/pages/Auth.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { api } from '../lib/api'; + +export default function Auth({ onAuth }: { onAuth: () => void }) { + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + if (mode === 'register') { + const res = await api.register(email, password); + if (res.error) { + setError(res.error.message); + return; + } + setMode('login'); + setPassword(''); + setError('Account created! Please log in.'); + } else { + const res = await api.login(email, password); + if (res.error) { + setError(res.error.message); + return; + } + + const keyRes = await api.createApiKey('Web App', res.data!.token); + if (keyRes.data) { + api.setApiKey(keyRes.data.apiKey); + onAuth(); + } + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+

TotalRecall

+ +
+
+ + +
+ +
+
+ + setEmail(e.target.value)} + className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + required + minLength={8} + /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Day.tsx b/frontend/src/pages/Day.tsx new file mode 100644 index 0000000..74f6707 --- /dev/null +++ b/frontend/src/pages/Day.tsx @@ -0,0 +1,78 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { api, Entry } from '../lib/api'; +import EntryInput from '../components/EntryInput'; +import EntryList from '../components/EntryList'; + +export default function Day() { + const { date } = useParams<{ date: string }>(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [hasJournal, setHasJournal] = useState(false); + + useEffect(() => { + if (date) loadEntries(); + }, [date]); + + const loadEntries = async () => { + if (!date) return; + setLoading(true); + const res = await api.getDay(date); + if (res.data) { + setEntries(res.data.entries); + setHasJournal(!!res.data.journal); + } + setLoading(false); + }; + + const handleAddEntry = async (type: string, content: string, metadata?: object) => { + if (!date) return { error: { message: 'No date' } }; + const res = await api.createEntry(date, type, content, metadata); + if (res.data) { + setEntries((prev) => [...prev, res.data!]); + } + return res; + }; + + const handleDeleteEntry = async (id: string) => { + const res = await api.deleteEntry(id); + if (res.data) { + setEntries((prev) => prev.filter((e) => e.id !== id)); + } + }; + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr + 'T12:00:00'); + return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); + }; + + if (!date) return null; + + return ( +
+
+
+ ← Back +

{formatDate(date)}

+
+ {hasJournal && ( + + View Journal + + )} +
+ + + + {loading ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
+

No entries for this day

+
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx new file mode 100644 index 0000000..3734e05 --- /dev/null +++ b/frontend/src/pages/History.tsx @@ -0,0 +1,82 @@ +import { useState, useEffect } from 'react'; +import { api } from '../lib/api'; + +interface DayInfo { + date: string; + entryCount: number; + hasJournal: boolean; +} + +export default function History() { + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadDays(); + }, []); + + const loadDays = async () => { + setLoading(true); + const res = await api.getDays(); + if (res.data) { + setDays(res.data); + } + setLoading(false); + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr + 'T12:00:00'); + return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); + }; + + const handleDelete = async (date: string) => { + if (confirm('Delete all entries for this day?')) { + await api.deleteDay(date); + loadDays(); + } + }; + + return ( +
+

History

+ + {loading ? ( +
Loading...
+ ) : days.length === 0 ? ( +
+

No entries yet

+

Start adding entries to see them here

+
+ ) : ( +
+ {days.map((day) => ( +
+
+ + {formatDate(day.date)} + + + {day.entryCount} {day.entryCount === 1 ? 'entry' : 'entries'} + + {day.hasJournal && ( + + Journal + + )} +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..5e1b088 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from 'react'; +import { api, Entry } from '../lib/api'; +import EntryInput from '../components/EntryInput'; +import EntryList from '../components/EntryList'; + +export default function Home() { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + const today = new Date().toISOString().split('T')[0]; + + useEffect(() => { + loadEntries(); + }, []); + + const loadEntries = async () => { + setLoading(true); + const res = await api.getDay(today); + if (res.data) { + setEntries(res.data.entries); + } + setLoading(false); + }; + + const handleAddEntry = async (type: string, content: string, metadata?: object) => { + const res = await api.createEntry(today, type, content, metadata); + if (res.data) { + setEntries((prev) => [...prev, res.data!]); + } + return res; + }; + + const handleDeleteEntry = async (id: string) => { + const res = await api.deleteEntry(id); + if (res.data) { + setEntries((prev) => prev.filter((e) => e.id !== id)); + } + }; + + const handleGenerateJournal = async () => { + setGenerating(true); + await api.generateJournal(today); + setGenerating(false); + }; + + return ( +
+
+
+

Today

+

{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}

+
+ +
+ + + + {loading ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
+

No entries yet today

+

Start capturing your day above

+
+ ) : ( + + )} + + {entries.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/pages/Journal.tsx b/frontend/src/pages/Journal.tsx new file mode 100644 index 0000000..1d52182 --- /dev/null +++ b/frontend/src/pages/Journal.tsx @@ -0,0 +1,86 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { api, Journal } from '../lib/api'; + +export default function JournalPage() { + const { date } = useParams<{ date: string }>(); + const [journal, setJournal] = useState(null); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + + useEffect(() => { + if (date) loadJournal(); + }, [date]); + + const loadJournal = async () => { + if (!date) return; + setLoading(true); + const res = await api.getJournal(date); + if (res.data) { + setJournal(res.data); + } + setLoading(false); + }; + + const handleGenerate = async () => { + if (!date) return; + setGenerating(true); + const res = await api.generateJournal(date); + if (res.data) { + setJournal(res.data); + } + setGenerating(false); + }; + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr + 'T12:00:00'); + return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); + }; + + if (!date) return null; + + return ( +
+
+ ← Back to day +

Journal

+

{formatDate(date)}

+
+ + {loading ? ( +
Loading...
+ ) : !journal ? ( +
+

No journal generated yet

+ +
+ ) : ( +
+
+ Generated {new Date(journal.generatedAt).toLocaleString()} • {journal.entryCount} entries +
+
+
+ {journal.content} +
+
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..8eca8d9 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect } from 'react'; +import { api, Settings } from '../lib/api'; + +export default function SettingsPage() { + const [settings, setSettings] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + useEffect(() => { + loadSettings(); + }, []); + + const loadSettings = async () => { + setLoading(true); + const res = await api.getSettings(); + if (res.data) { + setSettings(res.data); + } + setLoading(false); + }; + + const handleSave = async () => { + setSaving(true); + setSaved(false); + await api.updateSettings(settings); + setSaving(false); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + const handleLogout = () => { + api.clearApiKey(); + window.location.href = '/auth'; + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+

Settings

+ +
+
+

AI Provider

+ +
+
+ + +
+ + {(settings.aiProvider === 'openai' || settings.aiProvider === 'anthropic') && ( +
+ + setSettings({ ...settings, aiApiKey: e.target.value })} + placeholder="sk-..." + className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + /> +
+ )} + + {(settings.aiProvider === 'ollama' || settings.aiProvider === 'lmstudio') && ( +
+ + setSettings({ ...settings, aiBaseUrl: e.target.value })} + placeholder={settings.aiProvider === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234/v1'} + className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + /> +
+ )} + +
+ + setSettings({ ...settings, aiModel: e.target.value })} + placeholder={settings.aiProvider === 'openai' ? 'gpt-4' : settings.aiProvider === 'anthropic' ? 'claude-3-sonnet-20240229' : 'llama3.2'} + className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none" + /> +
+
+
+ +
+

Journal Generation

+ +
+
+ +