Initial commit: deardiary project setup

This commit is contained in:
lotherk
2026-03-26 19:57:20 +00:00
commit 3f9bc1f484
73 changed files with 8627 additions and 0 deletions

40
.gitignore vendored Normal file
View File

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

46
Dockerfile.old Normal file
View File

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

444
PLAN.md.old Normal file
View File

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

131
README.md Normal file
View File

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

130
README.md.old Normal file
View File

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

73
android/README.md Normal file
View File

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

View File

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

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TotalRecall"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.TotalRecall">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

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

View File

@@ -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<T>(
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<Entry>,
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 <T> request(
method: String,
path: String,
body: Any? = null,
authenticated: Boolean = true
): ApiResponse<T> = 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<ApiResponse<T>>() {}.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<Map<String, User>> {
return request("POST", "auth/register", mapOf("email" to email, "password" to password), false)
}
suspend fun login(email: String, password: String): ApiResponse<LoginResponse> {
return request("POST", "auth/login", mapOf("email" to email, "password" to password), false)
}
suspend fun createApiKey(name: String, token: String): ApiResponse<CreateApiKeyResponse> {
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<List<DayInfo>> {
return request("GET", "days")
}
suspend fun getDay(date: String): ApiResponse<DayResponse> {
return request("GET", "days/$date")
}
suspend fun createEntry(date: String, type: String, content: String): ApiResponse<Entry> {
return request("POST", "entries", mapOf(
"date" to date,
"type" to type,
"content" to content
))
}
suspend fun deleteEntry(id: String): ApiResponse<Map<String, Boolean>> {
return request("DELETE", "entries/$id")
}
suspend fun generateJournal(date: String): ApiResponse<Journal> {
return request("POST", "journal/generate/$date")
}
suspend fun getJournal(date: String): ApiResponse<Journal> {
return request("GET", "journal/$date")
}
suspend fun getSettings(): ApiResponse<Settings> {
return request("GET", "settings")
}
suspend fun updateSettings(settings: Settings): ApiResponse<Settings> {
return request("PUT", "settings", settings)
}
}

View File

@@ -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<Preferences> 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<Unit> {
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<Unit> {
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<List<DayInfo>> {
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<DayResponse> {
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<Entry> {
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<Unit> {
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<Journal> {
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<Journal> {
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<Settings> {
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<Settings> {
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"))
}
}

View File

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

View File

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

View File

@@ -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<Unit>,
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<String?>(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")
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = Repository(application, BuildConfig.API_BASE_URL)
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn
private val _authState = MutableStateFlow<UiState<Unit>>(UiState.Loading)
val authState: StateFlow<UiState<Unit>> = _authState
private val _days = MutableStateFlow<List<DayInfo>>(emptyList())
val days: StateFlow<List<DayInfo>> = _days
private val _currentDay = MutableStateFlow<DayResponse?>(null)
val currentDay: StateFlow<DayResponse?> = _currentDay
private val _journal = MutableStateFlow<Journal?>(null)
val journal: StateFlow<Journal?> = _journal
private val _settings = MutableStateFlow<Settings?>(null)
val settings: StateFlow<Settings?> = _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 }
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TotalRecall</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TotalRecall" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/black</item>
</style>
</resources>

4
android/build.gradle.kts Normal file
View File

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

View File

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

View File

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

248
android/gradlew vendored Executable file
View File

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

View File

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

20
backend/.env.example Normal file
View File

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

39
backend/Dockerfile Normal file
View File

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

50
backend/bun.lock Normal file
View File

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

26
backend/package.json Normal file
View File

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

View File

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

588
backend/src/index.ts Normal file
View File

@@ -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<string | null> => {
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<string, unknown> = {};
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,
};

15
backend/src/lib/types.ts Normal file
View File

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

View File

@@ -0,0 +1,39 @@
import { Context, Next } from 'hono';
import { HonoEnv } from '../lib/types';
import { createHash } from 'crypto';
export async function authMiddleware(c: Context<HonoEnv>, 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<HonoEnv>, next: Next) => {
await authMiddleware(c, next);
};
}

115
backend/src/routes/auth.ts Normal file
View File

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

View File

@@ -0,0 +1,68 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const daysRoutes = new Hono<HonoEnv>();
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 });
});

View File

@@ -0,0 +1,166 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const entriesRoutes = new Hono<HonoEnv>();
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);
});

View File

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

View File

@@ -0,0 +1,69 @@
import { Hono } from 'hono';
import { HonoEnv } from '../lib/types';
export const settingsRoutes = new Hono<HonoEnv>();
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<string, unknown> = {};
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 });
}
});

View File

@@ -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<string> {
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<boolean> {
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;
}
}
}

View File

@@ -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<string> {
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<boolean> {
try {
const response = await fetch(`${this.baseUrl}/models`);
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -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<string> {
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<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -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<string> {
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<boolean> {
try {
const response = await fetch(`${this.baseUrl}/models`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
});
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,32 @@
export interface AIProvider {
provider: 'openai' | 'anthropic' | 'ollama' | 'lmstudio';
generate(prompt: string, systemPrompt?: string): Promise<string>;
validate?(): Promise<boolean>;
}
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}`);
}
}

21
backend/tsconfig.json Normal file
View File

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

View File

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

24
docker-compose.yml.old Normal file
View File

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

21
frontend/Dockerfile Normal file
View File

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

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e293b" />
<link rel="manifest" href="/manifest.json" />
<title>TotalRecall - AI Journal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
frontend/nginx.conf Normal file
View File

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

2808
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

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

View File

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

View File

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

94
frontend/src/App.tsx Normal file
View File

@@ -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<boolean | null>(null);
useEffect(() => {
const key = api.getApiKey();
setIsAuthenticated(!!key);
}, []);
if (isAuthenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-slate-400">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/auth" replace />;
}
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 (
<div className="min-h-screen flex items-center justify-center bg-slate-950">
<div className="animate-pulse text-slate-400">Loading...</div>
</div>
);
}
return (
<BrowserRouter>
<div className="min-h-screen bg-slate-950 text-slate-100">
{isAuthenticated && (
<nav className="bg-slate-900 border-b border-slate-800 sticky top-0 z-50">
<div className="max-w-4xl mx-auto px-4 py-3 flex gap-6">
<a href="/" className="text-slate-300 hover:text-white transition">Today</a>
<a href="/history" className="text-slate-300 hover:text-white transition">History</a>
<a href="/settings" className="text-slate-300 hover:text-white transition">Settings</a>
</div>
</nav>
)}
<Routes>
<Route path="/auth" element={
isAuthenticated ? <Navigate to="/" replace /> : <Auth onAuth={handleAuth} />
} />
<Route path="/" element={
<PrivateRoute><Home /></PrivateRoute>
} />
<Route path="/history" element={
<PrivateRoute><History /></PrivateRoute>
} />
<Route path="/day/:date" element={
<PrivateRoute><Day /></PrivateRoute>
} />
<Route path="/journal/:date" element={
<PrivateRoute><Journal /></PrivateRoute>
} />
<Route path="/settings" element={
<PrivateRoute><Settings /></PrivateRoute>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</BrowserRouter>
);
}
export default App;

View File

@@ -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<HTMLInputElement>(null);
const [recording, setRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(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<HTMLInputElement>) => {
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 (
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex gap-2 mb-2">
<button
type="button"
onClick={() => setType('text')}
className={`px-3 py-1 rounded text-sm ${type === 'text' ? 'bg-slate-700' : 'bg-slate-800'}`}
>
Text
</button>
<button
type="button"
onClick={() => setType('photo')}
className={`px-3 py-1 rounded text-sm ${type === 'photo' ? 'bg-slate-700' : 'bg-slate-800'}`}
>
Photo
</button>
<button
type="button"
onClick={() => setType('voice')}
className={`px-3 py-1 rounded text-sm ${type === 'voice' ? 'bg-slate-700' : 'bg-slate-800'}`}
>
Voice
</button>
<button
type="button"
onClick={() => setType('health')}
className={`px-3 py-1 rounded text-sm ${type === 'health' ? 'bg-slate-700' : 'bg-slate-800'}`}
>
Health
</button>
</div>
{type === 'text' && (
<div className="flex gap-2">
<input
type="text"
value={content}
onChange={(e) => 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
/>
<button
type="submit"
disabled={loading || !content.trim()}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition disabled:opacity-50"
>
Add
</button>
</div>
)}
{type === 'photo' && (
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handlePhotoUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={loading}
className="w-full py-3 bg-slate-700 hover:bg-slate-600 rounded-lg transition disabled:opacity-50"
>
Take Photo or Choose from Gallery
</button>
</div>
)}
{type === 'voice' && (
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
{recording ? (
<div className="flex items-center justify-center gap-4">
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse" />
<span className="text-slate-300">Recording...</span>
<button
type="button"
onClick={stopRecording}
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg font-medium transition"
>
Stop
</button>
</div>
) : (
<button
type="button"
onClick={startRecording}
disabled={loading}
className="w-full py-3 bg-slate-700 hover:bg-slate-600 rounded-lg transition disabled:opacity-50"
>
Start Recording
</button>
)}
</div>
)}
{type === 'health' && (
<div className="space-y-3">
<input
type="text"
value={content}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={loading || !content.trim()}
className="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition disabled:opacity-50"
>
Log Health
</button>
</div>
)}
{error && <p className="mt-2 text-red-400 text-sm">{error}</p>}
</form>
);
}

View File

@@ -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 (
<div className="space-y-3">
{entries.map((entry) => (
<div
key={entry.id}
className={`bg-slate-900 rounded-lg p-4 border border-slate-800 border-l-4 ${getTypeColor(entry.type)}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{getTypeIcon(entry.type)}</span>
<span className="text-xs text-slate-500">{formatTime(entry.createdAt)}</span>
</div>
<p className="text-slate-200">{entry.content}</p>
{entry.metadata && (
<div className="mt-2 text-xs text-slate-500">
{(() => {
try {
const meta = JSON.parse(entry.metadata);
return meta.location ? (
<span>📍 {meta.location.lat?.toFixed(4)}, {meta.location.lng?.toFixed(4)}</span>
) : meta.duration ? (
<span> {meta.duration}s</span>
) : null;
} catch {
return null;
}
})()}
</div>
)}
</div>
<button
onClick={() => onDelete(entry.id)}
className="text-slate-500 hover:text-red-400 text-sm transition"
>
×
</button>
</div>
</div>
))}
</div>
);
}

7
frontend/src/index.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-950 text-slate-100;
}

172
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,172 @@
const API_BASE = '/api/v1';
interface ApiResponse<T> {
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<T>(
method: string,
path: string,
body?: unknown
): Promise<ApiResponse<T>> {
const headers: Record<string, string> = {
'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<ApiResponse<T>>;
}
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<string, string> = {
'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<ApiResponse<{ apiKey: string }>>;
}
async getDays() {
return this.request<Array<{ date: string; entryCount: number; hasJournal: boolean }>>('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<Entry>('POST', '/entries', { date, type, content, metadata });
}
async updateEntry(id: string, content: string, metadata?: object) {
return this.request<Entry>('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<string, string> = {};
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<ApiResponse<{ mediaPath: string }>>;
}
async uploadVoice(entryId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
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<ApiResponse<{ mediaPath: string }>>;
}
async generateJournal(date: string) {
return this.request<Journal>('POST', `/journal/generate/${date}`);
}
async getJournal(date: string) {
return this.request<Journal>('GET', `/journal/${date}`);
}
async getSettings() {
return this.request<Settings>('GET', '/settings');
}
async updateSettings(settings: Partial<Settings>) {
return this.request<Settings>('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();

10
frontend/src/main.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);

104
frontend/src/pages/Auth.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
<h1 className="text-3xl font-bold text-center mb-8 text-slate-100">TotalRecall</h1>
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<div className="flex gap-4 mb-6">
<button
onClick={() => setMode('login')}
className={`flex-1 py-2 rounded-lg transition ${
mode === 'login' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Login
</button>
<button
onClick={() => setMode('register')}
className={`flex-1 py-2 rounded-lg transition ${
mode === 'register' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white'
}`}
>
Register
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => 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}
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition disabled:opacity-50"
>
{loading ? 'Please wait...' : mode === 'login' ? 'Login' : 'Create Account'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -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<Entry[]>([]);
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 (
<div className="max-w-4xl mx-auto p-4">
<div className="flex items-center justify-between mb-6">
<div>
<a href="/history" className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back</a>
<h1 className="text-2xl font-bold">{formatDate(date)}</h1>
</div>
{hasJournal && (
<a href={`/journal/${date}`} className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition">
View Journal
</a>
)}
</div>
<EntryInput onSubmit={handleAddEntry} />
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : entries.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400">No entries for this day</p>
</div>
) : (
<EntryList entries={entries} onDelete={handleDeleteEntry} />
)}
</div>
);
}

View File

@@ -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<DayInfo[]>([]);
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 (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">History</h1>
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : days.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400">No entries yet</p>
<p className="text-slate-500 text-sm">Start adding entries to see them here</p>
</div>
) : (
<div className="space-y-2">
{days.map((day) => (
<div
key={day.date}
className="flex items-center justify-between p-4 bg-slate-900 rounded-lg border border-slate-800"
>
<div className="flex items-center gap-4">
<a href={`/day/${day.date}`} className="font-medium hover:text-blue-400">
{formatDate(day.date)}
</a>
<span className="text-slate-400 text-sm">
{day.entryCount} {day.entryCount === 1 ? 'entry' : 'entries'}
</span>
{day.hasJournal && (
<a href={`/journal/${day.date}`} className="text-purple-400 text-sm hover:text-purple-300">
Journal
</a>
)}
</div>
<button
onClick={() => handleDelete(day.date)}
className="text-slate-500 hover:text-red-400 text-sm transition"
>
Delete
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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<Entry[]>([]);
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 (
<div className="max-w-4xl mx-auto p-4">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">Today</h1>
<p className="text-slate-400">{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}</p>
</div>
<button
onClick={handleGenerateJournal}
disabled={generating || entries.length === 0}
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Journal'}
</button>
</div>
<EntryInput onSubmit={handleAddEntry} />
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : entries.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-2">No entries yet today</p>
<p className="text-slate-500 text-sm">Start capturing your day above</p>
</div>
) : (
<EntryList entries={entries} onDelete={handleDeleteEntry} />
)}
{entries.length > 0 && (
<div className="mt-6 text-center">
<a href={`/journal/${today}`} className="text-purple-400 hover:text-purple-300 text-sm">
View journal
</a>
</div>
)}
</div>
);
}

View File

@@ -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<Journal | null>(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 (
<div className="max-w-4xl mx-auto p-4">
<div className="mb-6">
<a href={`/day/${date}`} className="text-slate-400 hover:text-white text-sm mb-1 inline-block"> Back to day</a>
<h1 className="text-2xl font-bold">Journal</h1>
<p className="text-slate-400">{formatDate(date)}</p>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : !journal ? (
<div className="text-center py-12">
<p className="text-slate-400 mb-4">No journal generated yet</p>
<button
onClick={handleGenerate}
disabled={generating}
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 rounded-lg font-medium transition disabled:opacity-50"
>
{generating ? 'Generating...' : 'Generate Journal'}
</button>
</div>
) : (
<div>
<div className="text-sm text-slate-400 mb-4">
Generated {new Date(journal.generatedAt).toLocaleString()} {journal.entryCount} entries
</div>
<div className="prose prose-invert prose-slate max-w-none">
<div className="bg-slate-900 rounded-xl p-6 border border-slate-800 whitespace-pre-wrap leading-relaxed">
{journal.content}
</div>
</div>
<div className="mt-6 flex gap-4">
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded-lg text-sm transition disabled:opacity-50"
>
Regenerate
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import { api, Settings } from '../lib/api';
export default function SettingsPage() {
const [settings, setSettings] = useState<Partial<Settings>>({});
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 (
<div className="max-w-2xl mx-auto p-4">
<div className="text-center py-12 text-slate-400">Loading...</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="space-y-6">
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<h2 className="text-lg font-medium mb-4">AI Provider</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">Provider</label>
<select
value={settings.aiProvider || 'openai'}
onChange={(e) => setSettings({ ...settings, aiProvider: e.target.value as Settings['aiProvider'] })}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none"
>
<option value="openai">OpenAI (GPT-4)</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="ollama">Ollama (Local)</option>
<option value="lmstudio">LM Studio (Local)</option>
</select>
</div>
{(settings.aiProvider === 'openai' || settings.aiProvider === 'anthropic') && (
<div>
<label className="block text-sm text-slate-400 mb-1">API Key</label>
<input
type="password"
value={settings.aiApiKey || ''}
onChange={(e) => 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"
/>
</div>
)}
{(settings.aiProvider === 'ollama' || settings.aiProvider === 'lmstudio') && (
<div>
<label className="block text-sm text-slate-400 mb-1">Base URL</label>
<input
type="text"
value={settings.aiBaseUrl || ''}
onChange={(e) => 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"
/>
</div>
)}
<div>
<label className="block text-sm text-slate-400 mb-1">Model</label>
<input
type="text"
value={settings.aiModel || ''}
onChange={(e) => 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"
/>
</div>
</div>
</section>
<section className="bg-slate-900 rounded-xl p-6 border border-slate-800">
<h2 className="text-lg font-medium mb-4">Journal Generation</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-slate-400 mb-1">System Prompt</label>
<textarea
value={settings.journalPrompt || ''}
onChange={(e) => setSettings({ ...settings, journalPrompt: e.target.value })}
rows={4}
className="w-full px-4 py-2 bg-slate-800 rounded-lg border border-slate-700 focus:border-blue-500 focus:outline-none resize-none"
placeholder="Instructions for the AI journal writer..."
/>
</div>
<div>
<label className="block text-sm text-slate-400 mb-1">Language</label>
<select
value={settings.language || 'en'}
onChange={(e) => setSettings({ ...settings, language: 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"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="ja"></option>
<option value="zh"></option>
</select>
</div>
</div>
</section>
<div className="flex items-center justify-between">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg font-medium transition disabled:opacity-50"
>
{saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'}
</button>
<button
onClick={handleLogout}
className="px-4 py-2 text-red-400 hover:text-red-300 transition"
>
Logout
</button>
</div>
</div>
</div>
);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/EntryInput.tsx","./src/components/EntryList.tsx","./src/lib/api.ts","./src/pages/Auth.tsx","./src/pages/Day.tsx","./src/pages/History.tsx","./src/pages/Home.tsx","./src/pages/Journal.tsx","./src/pages/Settings.tsx"],"version":"5.9.3"}

15
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

21
nginx.conf.old Normal file
View File

@@ -0,0 +1,21 @@
server {
listen 80;
root /app/public;
index index.html;
client_body_temp_path /app/nginx_tmp/client_body;
proxy_temp_path /app/nginx_tmp/proxy;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1: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;
}
}