1
apps/web
41
apps/web/.gitignore
vendored
Executable file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
apps/web/AGENTS.md
Executable file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
apps/web/CLAUDE.md
Executable file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
39
apps/web/Dockerfile
Executable file
@@ -0,0 +1,39 @@
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
RUN npm ci && \
|
||||
for pkg in \
|
||||
lightningcss-linux-x64-gnu \
|
||||
lightningcss-linux-x64-musl \
|
||||
@tailwindcss/oxide-linux-x64-gnu \
|
||||
@tailwindcss/oxide-linux-x64-musl; do \
|
||||
if [ -d "node_modules/$pkg" ]; then \
|
||||
mkdir -p "apps/web/node_modules/$(dirname "$pkg")"; \
|
||||
cp -a "node_modules/$pkg" "apps/web/node_modules/$pkg"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY . .
|
||||
RUN npm run build -w @adventureos/web
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
36
apps/web/README.md
Executable file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
66
apps/web/docs/ARCHITECTURE.md
Executable file
@@ -0,0 +1,66 @@
|
||||
# AdventureOS Architecture
|
||||
|
||||
AdventureOS is a Next.js 16 full-stack monolith with npm workspaces.
|
||||
|
||||
## Layers
|
||||
|
||||
```
|
||||
Browser (React + TanStack Query + Zustand)
|
||||
↓ fetch /api/*
|
||||
Next.js Route Handlers (app/api/**)
|
||||
↓ validation (lib/validation) + handleApi (lib/api)
|
||||
Domain Services (lib/services/**)
|
||||
↓
|
||||
Repositories (lib/repositories/**) — Drizzle queries
|
||||
Packages: @adventureos/db, @adventureos/shared
|
||||
```
|
||||
|
||||
## Frontend boundaries
|
||||
|
||||
| Path | Responsibility |
|
||||
|------|----------------|
|
||||
| `app/*/page.tsx` | Route pages, data fetching via React Query |
|
||||
| `components/features/` | Domain UI components |
|
||||
| `components/ui/` | Reusable retro UI primitives |
|
||||
| `features/*/api.ts` | Typed client fetch wrappers |
|
||||
| `hooks/` | Shared React hooks (mutations, toasts) |
|
||||
| `stores/ui.ts` | Ephemeral UI state (toasts, modals) |
|
||||
| `themes/` | CSS token registry and runtime theme switching |
|
||||
|
||||
## Backend boundaries
|
||||
|
||||
| Path | Responsibility |
|
||||
|------|----------------|
|
||||
| `app/api/**/route.ts` | HTTP entry; parse/validate; call services |
|
||||
| `lib/services/adventure/` | Daily adventures, materialization, scoring, templates |
|
||||
| `lib/services/reading/` | Shared reading log + action recording |
|
||||
| `lib/services/ai*.ts` | AI generation, config, memory, chat, context |
|
||||
| `lib/services/ai-memory.ts` | User-controlled personal AI memories |
|
||||
| `lib/services/ai-context.ts` | Layered context builder for local models |
|
||||
| `lib/services/ai-chat.ts` | Mentor chat sessions and messages |
|
||||
| `lib/services/day-boundary.ts` | Configurable day rollover (after-midnight logging) |
|
||||
| `lib/repositories/` | Drizzle data access only |
|
||||
| `lib/config/` | Environment variables and domain constants |
|
||||
| `lib/errors/` | Typed errors and response mapping |
|
||||
| `lib/validation/` | Zod request schemas |
|
||||
|
||||
## Data stores
|
||||
|
||||
- **PostgreSQL** — primary app data via Drizzle (`packages/db`)
|
||||
- **Calibre metadata.db** — read-only SQLite via `better-sqlite3` when configured
|
||||
|
||||
## Service boundaries (rules)
|
||||
|
||||
1. Route handlers must not import Drizzle directly (use services/repositories).
|
||||
2. Repositories contain no business rules.
|
||||
3. Services orchestrate repositories, shared math, XP, and action events.
|
||||
4. Client components use `features/*/api.ts`, not raw fetch scattered in UI.
|
||||
5. `@adventureos/shared` holds pure domain types and formulas — no I/O.
|
||||
|
||||
## Known gaps
|
||||
|
||||
See [KNOWN-ISSUES.md](./KNOWN-ISSUES.md).
|
||||
|
||||
## Refactor history
|
||||
|
||||
See [REFACTOR-LOG.md](./REFACTOR-LOG.md).
|
||||
41
apps/web/docs/KNOWN-ISSUES.md
Executable file
@@ -0,0 +1,41 @@
|
||||
# Known Issues
|
||||
|
||||
Issues discovered during refactor audit. Not fixed unless explicitly approved (behaviour preservation).
|
||||
|
||||
## Calibre reading undo is broken
|
||||
|
||||
Both manual (`reading.ts`) and Calibre (`calibre-reading.ts`) use `actionType: "reading.log_pages"`.
|
||||
|
||||
- Manual: `beforeState: { book: { currentPage, status, finishedAt } }`, `entityType: "book"`
|
||||
- Calibre: `beforeState: { currentPage, status }`, `entityType: "reading_progress"`
|
||||
|
||||
`undo.ts` case `"reading.log_pages"` only restores the manual `books` table. Undoing Calibre page logs will fail or corrupt state.
|
||||
|
||||
## Cron middleware requires session
|
||||
|
||||
`middleware.ts` does not exempt `/api/cron`. External cron with only `x-cron-secret` (per README) receives 401 before the route handler runs.
|
||||
|
||||
## Partial undo coverage
|
||||
|
||||
- `applyTemplateToDate` records no action event
|
||||
- Template item create/update not recorded (only delete)
|
||||
|
||||
**Fixed:** Reflection saves now record `reflection.save` action events for undo.
|
||||
|
||||
## HTTP status codes
|
||||
|
||||
`handleApi` maps thrown `"Item not found"` to 500, not 404. Normalising would change API contract.
|
||||
|
||||
## Dashboard may ignore Calibre progress
|
||||
|
||||
Stats and dashboard reading metrics query manual `books` / `reading_logs`; Calibre `reading_progress` may not feed scores.
|
||||
|
||||
## Dead / unused code
|
||||
|
||||
- `packages/shared/src/titles.ts` — exported, never imported
|
||||
- `action_events.inversePatch` — written, never read in undo
|
||||
- `getCalibreBook()` in calibre.ts — no-op implementation
|
||||
|
||||
## Undo UX inconsistency
|
||||
|
||||
Toast undo (`overlays.tsx`) reloads the page; settings undo invalidates React Query cache.
|
||||
74
apps/web/docs/REFACTOR-LOG.md
Executable file
@@ -0,0 +1,74 @@
|
||||
# Refactor Log
|
||||
|
||||
Internal refactor of AdventureOS. Behaviour, API contracts, and schema preserved unless explicitly noted.
|
||||
|
||||
## Phase 1: Safety baseline
|
||||
|
||||
- Removed debug telemetry from `apps/web/src/app/api/library/books/route.ts`
|
||||
- Extracted pure adventure helpers: `derive-state.ts`, `template-matching.ts`, `checklist-value.ts`
|
||||
- Added unit tests for `@adventureos/shared` (xp, levels, scores, chapters)
|
||||
- Added characterization tests for deriveState, template matching, undo Calibre mismatch
|
||||
- Replaced misnamed `feature-repair.test.ts` with real tests
|
||||
|
||||
### Manual smoke checklist
|
||||
|
||||
- [ ] Login / logout
|
||||
- [ ] Command Centre: adventure items, todos, rest day, customize
|
||||
- [ ] Settings: theme, AI config, templates, action history undo
|
||||
- [ ] Library: manual books and Calibre (if configured)
|
||||
- [ ] Cartographer: generate, activate, complete, dismiss
|
||||
- [ ] Teacher: generate, complete, deep links
|
||||
- [ ] Undo toast and settings undo
|
||||
|
||||
### Commands run each phase
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run db:migrate # when DB available
|
||||
```
|
||||
|
||||
## Phase 2: Configuration and constants
|
||||
|
||||
- Centralised env reads in `lib/config/`
|
||||
- Typed constants for settings keys, action types, theme IDs
|
||||
|
||||
## Phase 3: API and data access
|
||||
|
||||
- Zod input validation on teacher, templates, settings routes
|
||||
- Repository layer for settings, teacher, adventure templates
|
||||
- Typed errors in `lib/errors/`; `handleApi` uses `mapErrorToResponse`
|
||||
- Teacher route uses `createTeacherLesson` service
|
||||
|
||||
## Phase 4: Domain/service cleanup
|
||||
|
||||
- Split `adventure.ts` into `lib/services/adventure/{materialization,daily,scoring,templates}.ts`
|
||||
- Unified Calibre reading log via `lib/services/reading/log-pages.ts`
|
||||
|
||||
## Phase 5: Frontend cleanup
|
||||
|
||||
- Client API: `features/adventure/api.ts`, `features/undo/api.ts`, `lib/api-client.ts`
|
||||
- Hooks: `useAdventureMutations`, `useUndoMutation`, `useActionToastHandlers`
|
||||
- Split `todays-adventure.tsx`; extracted `adventure-item-row`, `checklist-item`
|
||||
- Consolidated AI prompt editors into `AiPromptEditor`
|
||||
- Shared UI: `RetroWindow`, `TabToggle`, `LoadingState`, `ErrorState`
|
||||
- Undo toast uses query invalidation (no full page reload)
|
||||
|
||||
## Phase 6: Type safety
|
||||
|
||||
- JSONB interfaces in `lib/types/jsonb.ts`
|
||||
- Teacher types in `@adventureos/shared`
|
||||
- Removed unused `@radix-ui/*` dependencies
|
||||
|
||||
## Phase 7: Tests and CI
|
||||
|
||||
- Validation/error unit tests
|
||||
- `@adventureos/shared` test script and vitest config
|
||||
- Root `npm run test` runs web + shared
|
||||
- GitHub Actions CI workflow
|
||||
|
||||
## Phase 8: Documentation
|
||||
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md) — layers and service boundaries
|
||||
- [KNOWN-ISSUES.md](./KNOWN-ISSUES.md) — documented gaps
|
||||
18
apps/web/eslint.config.mjs
Executable file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
25
apps/web/next.config.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
import withSerwistInit from "@serwist/next";
|
||||
|
||||
const withSerwist = withSerwistInit({
|
||||
swSrc: "src/sw.ts",
|
||||
swDest: "public/sw.js",
|
||||
disable: process.env.NODE_ENV === "development",
|
||||
});
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
outputFileTracingRoot: path.join(__dirname, "../../"),
|
||||
transpilePackages: ["@adventureos/shared", "@adventureos/db"],
|
||||
serverExternalPackages: ["postgres"],
|
||||
webpack: (config) => {
|
||||
config.resolve.alias = {
|
||||
...(config.resolve.alias ?? {}),
|
||||
"@": path.join(__dirname, "src"),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default withSerwist(nextConfig);
|
||||
47
apps/web/package.json
Executable file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@adventureos/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adventureos/db": "*",
|
||||
"@adventureos/shared": "*",
|
||||
"@serwist/next": "^9.0.12",
|
||||
"@tanstack/react-query": "^5.67.2",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"iron-session": "^8.0.4",
|
||||
"next": "16.2.9",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"serwist": "^9.5.11",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.3.1",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "^4.3.1",
|
||||
"lightningcss-linux-x64-gnu": "^1.32.0",
|
||||
"lightningcss-linux-x64-musl": "^1.32.0"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Executable file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/web/public/file.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
apps/web/public/globe.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
5
apps/web/public/icons/icon.svg
Executable file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#3A6EA5"/>
|
||||
<rect x="64" y="64" width="384" height="384" fill="#F0E6D2" stroke="#fff" stroke-width="8"/>
|
||||
<text x="256" y="280" text-anchor="middle" font-family="Tahoma,sans-serif" font-size="120" font-weight="bold" fill="#2C2416">A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
16
apps/web/public/manifest.json
Executable file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "AdventureOS",
|
||||
"short_name": "AdventureOS",
|
||||
"description": "Your personal command centre for consistency and growth",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#5a8f3e",
|
||||
"theme_color": "#3A6EA5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
apps/web/public/next.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
2
apps/web/public/sw.js
Executable file
1
apps/web/public/vercel.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
apps/web/public/window.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
58
apps/web/src/app/achievements/page.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["achievements"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/achievements");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const unlockedKeys = new Set(
|
||||
(data?.unlocked ?? []).map((a: { key: string }) => a.key)
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4">
|
||||
<h1 className="font-bold text-lg mb-4">Achievement Gallery</h1>
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-6">
|
||||
Milestones on your long adventure — earned through showing up, not perfection.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p>Loading trophies...</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(data?.all ?? []).map(
|
||||
(a: { key: string; name: string; description: string; category: string }) => {
|
||||
const unlocked = unlockedKeys.has(a.key);
|
||||
return (
|
||||
<div
|
||||
key={a.key}
|
||||
className={`retro-window p-4 ${
|
||||
unlocked ? "" : "opacity-50 grayscale"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">{unlocked ? "🏆" : "🔒"}</span>
|
||||
<div>
|
||||
<h3 className="font-bold">{a.name}</h3>
|
||||
<p className="text-xs text-[var(--warm-grey)]">{a.description}</p>
|
||||
<span className="text-xs text-[var(--gold-trim)]">{a.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/app/api/achievements/route.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import {
|
||||
getAchievements,
|
||||
getAllAchievementDefinitions,
|
||||
checkAchievements,
|
||||
} from "@/lib/services/achievements";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const unlocked = await getAchievements(user.id);
|
||||
const all = getAllAchievementDefinitions();
|
||||
return { unlocked, all };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return checkAchievements(user.id);
|
||||
});
|
||||
}
|
||||
16
apps/web/src/app/api/actions/[id]/undo/route.ts
Executable file
@@ -0,0 +1,16 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { undoAction } from "@/lib/services/undo";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const result = await undoAction(user.id, id);
|
||||
if (!result.ok) throw new Error(result.error ?? "Undo failed");
|
||||
return result;
|
||||
});
|
||||
}
|
||||
11
apps/web/src/app/api/actions/recent/route.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getRecentActions } from "@/lib/services/action-events";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const actions = await getRecentActions(user.id, 30);
|
||||
return { actions };
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/adventures/[date]/apply-template/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { applyTemplateToDate } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return applyTemplateToDate(user.id, date, body.templateId, body.force === true);
|
||||
});
|
||||
}
|
||||
29
apps/web/src/app/api/adventures/[date]/items/[id]/route.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { updateAdventureItem, updateDailyItemMeta, deleteCustomDailyItem } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string; id: string }> }
|
||||
) {
|
||||
const { date, id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.label !== undefined || body.enabled !== undefined || body.config !== undefined) {
|
||||
return updateDailyItemMeta(user.id, date, id, body);
|
||||
}
|
||||
return updateAdventureItem(user.id, date, id, body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ date: string; id: string }> }
|
||||
) {
|
||||
const { date, id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return deleteCustomDailyItem(user.id, date, id);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/adventures/[date]/items/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { addCustomDailyItem } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return addCustomDailyItem(user.id, date, body);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/adventures/[date]/quick-log/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { quickLog } from "@/lib/services/quick-log";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return quickLog(user.id, date, body);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/adventures/[date]/rest-day/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { setRestDay } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const result = await setRestDay(user.id, date);
|
||||
return { ok: true, actionEventId: result.actionEventId };
|
||||
});
|
||||
}
|
||||
32
apps/web/src/app/api/adventures/[date]/route.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getDailyAdventure, updateDaySettings } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getDailyAdventure(user.id, date);
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.workHoursTarget !== undefined || body.dayMode !== undefined) {
|
||||
return updateDaySettings(user.id, date, {
|
||||
workHoursTarget: body.workHoursTarget,
|
||||
dayMode: body.dayMode,
|
||||
});
|
||||
}
|
||||
throw new Error("No valid updates");
|
||||
});
|
||||
}
|
||||
26
apps/web/src/app/api/adventures/[date]/todos/[id]/route.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { updateDailyTodo, deleteDailyTodo } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string; id: string }> }
|
||||
) {
|
||||
const { date, id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return updateDailyTodo(user.id, date, id, body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ date: string; id: string }> }
|
||||
) {
|
||||
const { date, id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return deleteDailyTodo(user.id, date, id);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/adventures/[date]/todos/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { createDailyTodo } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return createDailyTodo(user.id, date, body.label);
|
||||
});
|
||||
}
|
||||
11
apps/web/src/app/api/adventures/catch-up/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { detectCatchUpGaps } from "@/lib/services/catch-up";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const gaps = await detectCatchUpGaps(user.id);
|
||||
return { gaps };
|
||||
});
|
||||
}
|
||||
10
apps/web/src/app/api/ai/chat/knows/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getWhatAiKnows } from "@/lib/services/ai-chat";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getWhatAiKnows(user.id);
|
||||
});
|
||||
}
|
||||
40
apps/web/src/app/api/ai/chat/sessions/[id]/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getChatSession, sendChatMessage, archiveChatSession } from "@/lib/services/ai-chat";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const data = await getChatSession(user.id, id);
|
||||
if (!data) throw new Error("Session not found");
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await archiveChatSession(user.id, id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return sendChatMessage(user.id, id, body.content ?? "");
|
||||
});
|
||||
}
|
||||
19
apps/web/src/app/api/ai/chat/sessions/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { listChatSessions, createChatSession } from "@/lib/services/ai-chat";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const sessions = await listChatSessions(user.id);
|
||||
return { sessions };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return createChatSession(user.id, body.title, body.featureContext);
|
||||
});
|
||||
}
|
||||
33
apps/web/src/app/api/ai/config/route.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
getAiBehaviorConfig,
|
||||
getAiProviderConfig,
|
||||
saveAiBehaviorConfig,
|
||||
saveAiProviderConfig,
|
||||
} from "@/lib/services/ai-config";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const behavior = await getAiBehaviorConfig(user.id);
|
||||
const provider = await getAiProviderConfig(user.id);
|
||||
return { behavior, provider };
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
let behavior = await getAiBehaviorConfig(user.id);
|
||||
let provider = await getAiProviderConfig(user.id);
|
||||
if (body.behavior) {
|
||||
behavior = await saveAiBehaviorConfig(user.id, body.behavior);
|
||||
}
|
||||
if (body.provider) {
|
||||
provider = await saveAiProviderConfig(user.id, body.provider);
|
||||
}
|
||||
return { behavior, provider };
|
||||
});
|
||||
}
|
||||
19
apps/web/src/app/api/ai/context/preview/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { buildMentorContext, formatContextForPrompt } from "@/lib/services/ai-context";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const message = searchParams.get("message") ?? "";
|
||||
const feature = searchParams.get("feature") ?? "mentor";
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const ctx = await buildMentorContext(user.id, { userMessage: message, feature, logContext: false });
|
||||
return {
|
||||
layers: ctx.layers,
|
||||
tokenEstimate: ctx.tokenEstimate,
|
||||
memoryIds: ctx.memoryIds,
|
||||
formatted: formatContextForPrompt(ctx),
|
||||
};
|
||||
});
|
||||
}
|
||||
21
apps/web/src/app/api/ai/health/route.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getCachedHealth, runHealthCheck } from "@/lib/services/ai-config";
|
||||
import { getLastHealthLogs } from "@/lib/services/ai-config";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const cached = await getCachedHealth(user.id);
|
||||
const logs = await getLastHealthLogs(user.id, 5);
|
||||
return { health: cached, logs };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const health = await runHealthCheck(user.id);
|
||||
return { health };
|
||||
});
|
||||
}
|
||||
40
apps/web/src/app/api/ai/memory/[id]/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getMemory, updateMemory, archiveMemory } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const memory = await getMemory(user.id, id);
|
||||
if (!memory) throw new Error("Memory not found");
|
||||
return memory;
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return updateMemory(user.id, id, body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await archiveMemory(user.id, id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
21
apps/web/src/app/api/ai/memory/learning/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
getMemoryLearningSettings,
|
||||
saveMemoryLearningSettings,
|
||||
} from "@/lib/services/ai-memory";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getMemoryLearningSettings(user.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return saveMemoryLearningSettings(user.id, body);
|
||||
});
|
||||
}
|
||||
13
apps/web/src/app/api/ai/memory/reset/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { resetAllMemories } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (!body.confirm) throw new Error("Confirmation required");
|
||||
await resetAllMemories(user.id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
32
apps/web/src/app/api/ai/memory/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
listMemories,
|
||||
createMemory,
|
||||
getProfileSummary,
|
||||
listSuggestions,
|
||||
exportMemories,
|
||||
} from "@/lib/services/ai-memory";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const category = searchParams.get("category") ?? undefined;
|
||||
const q = searchParams.get("q") ?? undefined;
|
||||
const enabledParam = searchParams.get("enabled");
|
||||
const enabled = enabledParam === null ? undefined : enabledParam === "true";
|
||||
const memories = await listMemories(user.id, { category, q, enabled });
|
||||
const summary = await getProfileSummary(user.id);
|
||||
const pending = await listSuggestions(user.id, "pending");
|
||||
return { memories, summary, pendingCount: pending.length };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return createMemory(user.id, body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { acceptSuggestion } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return acceptSuggestion(user.id, id, body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { ignoreSuggestion } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await ignoreSuggestion(user.id, id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { rejectSuggestion } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await rejectSuggestion(user.id, id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
13
apps/web/src/app/api/ai/memory/suggestions/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { listSuggestions } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status") ?? "pending";
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const suggestions = await listSuggestions(user.id, status);
|
||||
return { suggestions };
|
||||
});
|
||||
}
|
||||
54
apps/web/src/app/api/ai/memory/summary/rebuild/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { listMemories, saveProfileSummary } from "@/lib/services/ai-memory";
|
||||
import { getProvider } from "@/lib/ai/provider-registry";
|
||||
import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "@/lib/services/ai-config";
|
||||
import { getTemplateBody } from "@/lib/services/ai-templates";
|
||||
import { renderTemplate } from "@/lib/ai/prompts/render";
|
||||
|
||||
export async function POST() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const memories = await listMemories(user.id, { enabled: true });
|
||||
const verified = memories.filter((m) => m.userVerified);
|
||||
|
||||
const bulletList = verified
|
||||
.map((m) => `- [${m.category}] ${m.title}: ${m.content.slice(0, 120)}`)
|
||||
.join("\n");
|
||||
|
||||
const behavior = await getAiBehaviorConfig(user.id);
|
||||
const providerConfig = await getAiProviderConfig(user.id);
|
||||
|
||||
let summaryText = verified.length === 0
|
||||
? "No verified memories yet. Add memories manually to build your profile."
|
||||
: bulletList.slice(0, 1500);
|
||||
|
||||
if (behavior.enabled && providerConfig.enabled && verified.length > 0) {
|
||||
try {
|
||||
const coreSystem = await getTemplateBody(user.id, "system_core");
|
||||
const templateBody = await getTemplateBody(user.id, "mentor_summary");
|
||||
const system = buildSystemPrompt(behavior, coreSystem);
|
||||
const prompt = renderTemplate(templateBody, {
|
||||
user_name: user.displayName,
|
||||
context: bulletList,
|
||||
});
|
||||
const provider = getProvider(providerConfig.type);
|
||||
const res = await provider.generateText(providerConfig, {
|
||||
model: providerConfig.model,
|
||||
prompt,
|
||||
system,
|
||||
format: "text",
|
||||
temperature: 0.5,
|
||||
maxTokens: 400,
|
||||
timeoutMs: providerConfig.timeoutMs,
|
||||
});
|
||||
if (res.text.trim()) summaryText = res.text.trim();
|
||||
} catch {
|
||||
/* use bullet list fallback */
|
||||
}
|
||||
}
|
||||
|
||||
const summary = await saveProfileSummary(user.id, summaryText);
|
||||
return { ...summary, sourceMemoryCount: verified.length };
|
||||
});
|
||||
}
|
||||
34
apps/web/src/app/api/ai/memory/summary/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
getProfileSummary,
|
||||
saveProfileSummary,
|
||||
exportMemories,
|
||||
importMemories,
|
||||
} from "@/lib/services/ai-memory";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getProfileSummary(user.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return saveProfileSummary(user.id, body.summary ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "import") {
|
||||
return importMemories(user.id, body.data, body.overwrite);
|
||||
}
|
||||
return exportMemories(user.id);
|
||||
});
|
||||
}
|
||||
23
apps/web/src/app/api/ai/models/route.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { listProviderTypes } from "@/lib/ai/provider-registry";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getAiProviderConfig } from "@/lib/services/ai-config";
|
||||
import { getProvider } from "@/lib/ai/provider-registry";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const providers = listProviderTypes();
|
||||
return { providers };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const config = await getAiProviderConfig(user.id);
|
||||
const provider = getProvider(config.type);
|
||||
if (!provider.listModels) return { models: [config.model] };
|
||||
const models = await provider.listModels(config);
|
||||
return { models };
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/ai/suggestions/[id]/dismiss/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { dismissSuggestion } from "@/lib/services/explorations";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await dismissSuggestion(id, user.id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
26
apps/web/src/app/api/ai/suggestions/route.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import { handleApi, jsonError } from "@/lib/api";
|
||||
import { getSuggestions, dismissSuggestion, generateDailyQuests } from "@/lib/services/explorations";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { isOllamaAvailable } from "@/lib/services/ai";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const role = searchParams.get("role") ?? undefined;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const suggestions = await getSuggestions(user.id, role);
|
||||
const ollamaAvailable = await isOllamaAvailable();
|
||||
return { suggestions, ollamaAvailable };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "generate_quests") {
|
||||
return generateDailyQuests(user.id);
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
64
apps/web/src/app/api/ai/templates/[key]/route.ts
Executable file
@@ -0,0 +1,64 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import {
|
||||
getPromptTemplate,
|
||||
updatePromptTemplate,
|
||||
resetPromptTemplate,
|
||||
previewTemplate,
|
||||
getTemplateVersions,
|
||||
restoreTemplateVersion,
|
||||
} from "@/lib/services/ai-templates";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
const { key } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const template = await getPromptTemplate(user.id, key);
|
||||
const versions = await getTemplateVersions(user.id, key);
|
||||
return { template, versions };
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
const { key } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const template = await updatePromptTemplate(user.id, key, body);
|
||||
return { template };
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ key: string }> }
|
||||
) {
|
||||
const { key } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action");
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
|
||||
if (action === "reset") {
|
||||
const template = await resetPromptTemplate(user.id, key);
|
||||
return { template };
|
||||
}
|
||||
if (action === "preview") {
|
||||
const rendered = await previewTemplate(user.id, key, body.body);
|
||||
return { rendered };
|
||||
}
|
||||
if (action === "restore" && body.version) {
|
||||
const template = await restoreTemplateVersion(user.id, key, body.version);
|
||||
return { template };
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
12
apps/web/src/app/api/ai/templates/route.ts
Executable file
@@ -0,0 +1,12 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getPromptTemplates } from "@/lib/services/ai-templates";
|
||||
import { PLACEHOLDER_DOCS } from "@/lib/ai/prompts/defaults";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const templates = await getPromptTemplates(user.id);
|
||||
return { templates, placeholders: PLACEHOLDER_DOCS };
|
||||
});
|
||||
}
|
||||
28
apps/web/src/app/api/auth/login/route.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
import { handleApi, jsonOk } from "@/lib/api";
|
||||
import { getSession, verifyPassword } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handleApi(async () => {
|
||||
const { password } = await request.json();
|
||||
if (!verifyPassword(password)) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
const session = await getSession();
|
||||
session.isLoggedIn = true;
|
||||
await session.save();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
return handleApi(async () => {
|
||||
const session = await getSession();
|
||||
session.destroy();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
return jsonOk({ isLoggedIn: !!session.isLoggedIn });
|
||||
}
|
||||
15
apps/web/src/app/api/books/[id]/log-pages/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { logPages } from "@/lib/services/reading";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return logPages(user.id, id, body.pages, body.date, body.note);
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/books/[id]/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { updateBook } from "@/lib/services/reading";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return updateBook(id, user.id, body);
|
||||
});
|
||||
}
|
||||
18
apps/web/src/app/api/books/route.ts
Executable file
@@ -0,0 +1,18 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getBooks, createBook } from "@/lib/services/reading";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getBooks(user.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return createBook(user.id, body);
|
||||
});
|
||||
}
|
||||
10
apps/web/src/app/api/cartographer/route.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getCartographerDesk } from "@/lib/services/cartographer";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getCartographerDesk(user.id);
|
||||
});
|
||||
}
|
||||
42
apps/web/src/app/api/cron/route.ts
Executable file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { verifyCronSecret } from "@/lib/auth";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { materializeDay } from "@/lib/services/adventure";
|
||||
import { todayString } from "@/lib/dates";
|
||||
import { generateDailyQuests, generateWeeklyExplorations } from "@/lib/services/explorations";
|
||||
import { weekStartString } from "@/lib/dates";
|
||||
import { generateWeeklyReview } from "@/lib/services/explorations";
|
||||
import { pruneOldActions } from "@/lib/services/action-events";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!verifyCronSecret(request)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { user } = await requireUser();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get("action") ?? "materialize";
|
||||
|
||||
if (action === "materialize") {
|
||||
await materializeDay(user.id, todayString());
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (action === "generate-quests") {
|
||||
await generateDailyQuests(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (action === "generate-explorations") {
|
||||
await generateWeeklyExplorations(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (action === "generate-weekly-review") {
|
||||
await generateWeeklyReview(user.id, weekStartString());
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (action === "prune-actions") {
|
||||
await pruneOldActions(user.id, 90);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
8
apps/web/src/app/api/dashboard/route.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getDashboard } from "@/lib/services/dashboard";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const date = searchParams.get("date") ?? undefined;
|
||||
return handleApi(() => getDashboard(date));
|
||||
}
|
||||
29
apps/web/src/app/api/explorations/[id]/route.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import {
|
||||
acceptExploration,
|
||||
completeExploration,
|
||||
dismissExploration,
|
||||
} from "@/lib/services/explorations";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { todayString } from "@/lib/dates";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "accept") {
|
||||
return acceptExploration(id, user.id);
|
||||
}
|
||||
if (body.action === "complete") {
|
||||
return completeExploration(id, user.id, body.note ?? "", body.date ?? todayString());
|
||||
}
|
||||
if (body.action === "dismiss" || body.action === "deny") {
|
||||
return dismissExploration(id, user.id);
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
33
apps/web/src/app/api/explorations/route.ts
Executable file
@@ -0,0 +1,33 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import {
|
||||
getExplorations,
|
||||
generateWeeklyExplorations,
|
||||
acceptExploration,
|
||||
completeExploration,
|
||||
dismissExploration,
|
||||
getExplorationHistory,
|
||||
} from "@/lib/services/explorations";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { todayString } from "@/lib/dates";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const weekOf = searchParams.get("week_of") ?? undefined;
|
||||
const history = searchParams.get("history") === "true";
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (history) return getExplorationHistory(user.id);
|
||||
return getExplorations(user.id, weekOf);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "generate") {
|
||||
return generateWeeklyExplorations(user.id);
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
25
apps/web/src/app/api/export/json/route.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@adventureos/db/schema";
|
||||
import { exportMemories } from "@/lib/services/ai-memory";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const aiData = await exportMemories(user.id);
|
||||
const tables = {
|
||||
user: await db.select().from(schema.users).where(eq(schema.users.id, user.id)),
|
||||
progress: await db.select().from(schema.userProgress),
|
||||
templates: await db.select().from(schema.adventureTemplates),
|
||||
books: await db.select().from(schema.books),
|
||||
achievements: await db.select().from(schema.achievements),
|
||||
reflections: await db.select().from(schema.reflections),
|
||||
aiMemories: aiData.memories,
|
||||
aiProfileSummary: aiData.summary,
|
||||
aiMemoryLearning: aiData.learning,
|
||||
};
|
||||
return tables;
|
||||
});
|
||||
}
|
||||
31
apps/web/src/app/api/library/books/route.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { listCalibreBooks } from "@/lib/services/calibre";
|
||||
import { getReadingProgress } from "@/lib/services/calibre-reading";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get("search") ?? undefined;
|
||||
const limit = Number(searchParams.get("limit") ?? "200");
|
||||
const offset = Number(searchParams.get("offset") ?? "0");
|
||||
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const books = await listCalibreBooks({ search, limit, offset });
|
||||
const progress = await getReadingProgress(user.id);
|
||||
const progressMap = new Map(progress.map((p) => [p.calibreBookId, p]));
|
||||
|
||||
return books.map((b) => {
|
||||
const p = progressMap.get(b.id);
|
||||
return {
|
||||
...b,
|
||||
currentPage: p?.currentPage ?? 0,
|
||||
totalPages: p?.totalPages ?? 300,
|
||||
status: p?.status ?? "unread",
|
||||
progressPercent: p
|
||||
? Math.round((p.currentPage / Math.max(p.totalPages, 1)) * 100)
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
30
apps/web/src/app/api/library/cover/[id]/route.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import fs from "fs";
|
||||
import { resolveCoverPath } from "@/lib/services/calibre";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const bookId = Number(id);
|
||||
if (!Number.isFinite(bookId)) {
|
||||
return new Response("Invalid id", { status: 400 });
|
||||
}
|
||||
|
||||
await requireUser();
|
||||
const coverPath = resolveCoverPath(bookId);
|
||||
if (!coverPath) {
|
||||
return new Response("Cover not found", { status: 404 });
|
||||
}
|
||||
const buffer = fs.readFileSync(coverPath);
|
||||
return new Response(buffer, {
|
||||
headers: { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=3600" },
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Error";
|
||||
if (msg === "Unauthorized") return new Response(msg, { status: 401 });
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
apps/web/src/app/api/library/progress/[id]/route.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { logCalibrePages } from "@/lib/services/calibre-reading";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { todayString } from "@/lib/dates";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const calibreBookId = Number(id);
|
||||
const body = await request.json();
|
||||
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return logCalibrePages(
|
||||
user.id,
|
||||
calibreBookId,
|
||||
body.pages ?? 10,
|
||||
body.date ?? todayString()
|
||||
);
|
||||
});
|
||||
}
|
||||
10
apps/web/src/app/api/library/status/route.ts
Executable file
@@ -0,0 +1,10 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getCalibreStatus } from "@/lib/services/calibre";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
await requireUser();
|
||||
return getCalibreStatus();
|
||||
});
|
||||
}
|
||||
26
apps/web/src/app/api/reflections/[date]/route.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { saveReflection, getReflection } from "@/lib/services/reflection";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return saveReflection(user.id, date, body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ date: string }> }
|
||||
) {
|
||||
const { date } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getReflection(user.id, date);
|
||||
});
|
||||
}
|
||||
37
apps/web/src/app/api/reviews/[weekStart]/route.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import {
|
||||
getWeeklyReview,
|
||||
generateWeeklyReview,
|
||||
setReviewIntention,
|
||||
} from "@/lib/services/explorations";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ weekStart: string }> }
|
||||
) {
|
||||
const { weekStart } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getWeeklyReview(user.id, weekStart);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ weekStart: string }> }
|
||||
) {
|
||||
const { weekStart } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "generate") {
|
||||
return generateWeeklyReview(user.id, weekStart);
|
||||
}
|
||||
if (body.intention) {
|
||||
await setReviewIntention(user.id, weekStart, body.intention);
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
20
apps/web/src/app/api/settings/day-boundary/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { getDayBoundaryHour, setDayBoundaryHour } from "@/lib/services/day-boundary";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const hour = await getDayBoundaryHour(user.id);
|
||||
return { hour };
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const body = await request.json();
|
||||
const hour = await setDayBoundaryHour(user.id, body.hour ?? 0);
|
||||
return { hour };
|
||||
});
|
||||
}
|
||||
58
apps/web/src/app/api/settings/route.ts
Executable file
@@ -0,0 +1,58 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { requireUser, updateUserProfile } from "@/lib/services/user";
|
||||
import { db, spiritualConfig } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { normalizeSettingsTheme } from "@/lib/services/theme-migration";
|
||||
import { migrateUserThemeInDb } from "@/lib/services/theme-migration";
|
||||
import { seedPromptTemplatesForUser } from "@/lib/services/ai-templates";
|
||||
import { getSettingsForUser, upsertSetting } from "@/lib/repositories/settings.repository";
|
||||
import { parseJsonBody, settingsPatchSchema } from "@/lib/validation";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const allSettings = await getSettingsForUser(user.id);
|
||||
const settingsMap = Object.fromEntries(allSettings.map((s) => [s.key, s.value]));
|
||||
const migratedTheme = await migrateUserThemeInDb(
|
||||
user.id,
|
||||
settingsMap.theme as string | undefined
|
||||
);
|
||||
settingsMap.theme = migratedTheme;
|
||||
const [spiritual] = await db
|
||||
.select()
|
||||
.from(spiritualConfig)
|
||||
.where(eq(spiritualConfig.userId, user.id));
|
||||
await seedPromptTemplatesForUser(user.id);
|
||||
return {
|
||||
user,
|
||||
settings: normalizeSettingsTheme(settingsMap),
|
||||
spiritual,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
return handleApi(async () => {
|
||||
const body = await parseJsonBody(request, settingsPatchSchema);
|
||||
const { user } = await requireUser();
|
||||
|
||||
if (body.profile) {
|
||||
await updateUserProfile(user.id, body.profile);
|
||||
}
|
||||
if (body.settings) {
|
||||
for (const [key, value] of Object.entries(body.settings)) {
|
||||
await upsertSetting(user.id, key, value);
|
||||
}
|
||||
}
|
||||
if (body.spiritual) {
|
||||
await db
|
||||
.update(spiritualConfig)
|
||||
.set({
|
||||
prayerLabels: body.spiritual.prayerLabels,
|
||||
litanyLabels: body.spiritual.litanyLabels,
|
||||
})
|
||||
.where(eq(spiritualConfig.userId, user.id));
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
15
apps/web/src/app/api/stats/[domain]/route.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getStatsOverview, getStatsDomain } from "@/lib/services/dashboard";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ domain: string }> }
|
||||
) {
|
||||
const { domain } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const range = Number(searchParams.get("range") ?? 30);
|
||||
return handleApi(async () => {
|
||||
if (domain === "overview") return getStatsOverview();
|
||||
return getStatsDomain(domain, range);
|
||||
});
|
||||
}
|
||||
31
apps/web/src/app/api/teacher/[id]/route.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getTeacherLesson, completeTeacherLesson } from "@/lib/services/teacher";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const lesson = await getTeacherLesson(user.id, id);
|
||||
if (!lesson) throw new Error("Lesson not found");
|
||||
return lesson;
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "complete") {
|
||||
return completeTeacherLesson(user.id, id, body.completedNote ?? "");
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
34
apps/web/src/app/api/teacher/route.ts
Executable file
@@ -0,0 +1,34 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { createTeacherLesson, getTeacherHistory } from "@/lib/services/teacher";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { parseJsonBody } from "@/lib/validation";
|
||||
import {
|
||||
teacherCreateSchema,
|
||||
normalizeTeacherCreateBody,
|
||||
} from "@/lib/validation/schemas";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handleApi(async () => {
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher POST start',data:{},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
|
||||
// #endregion
|
||||
const parsed = await parseJsonBody(request, teacherCreateSchema);
|
||||
const body = normalizeTeacherCreateBody(parsed);
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher body validated',data:{topicLen:body.topic.length,hasExplorationId:!!body.explorationId},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
|
||||
// #endregion
|
||||
const { user } = await requireUser();
|
||||
const result = await createTeacherLesson(user.id, body);
|
||||
// #region agent log
|
||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher lesson created',data:{source:result.source,lessonId:result.id},timestamp:Date.now(),hypothesisId:'H2'})}).catch(()=>{});
|
||||
// #endregion
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getTeacherHistory(user.id);
|
||||
});
|
||||
}
|
||||
32
apps/web/src/app/api/templates/[id]/items/route.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { upsertTemplateItem, deleteTemplateItem, verifyTemplateOwnership } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await verifyTemplateOwnership(user.id, id);
|
||||
const itemId = await upsertTemplateItem(id, body);
|
||||
return { id: itemId };
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const itemId = searchParams.get("itemId");
|
||||
if (!itemId) throw new Error("itemId required");
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
const result = await deleteTemplateItem(itemId, user.id);
|
||||
return { ok: true, ...result };
|
||||
});
|
||||
}
|
||||
47
apps/web/src/app/api/templates/[id]/route.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import {
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
verifyTemplateOwnership,
|
||||
} from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
await verifyTemplateOwnership(user.id, id);
|
||||
return updateTemplate(user.id, id, body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return deleteTemplate(user.id, id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
if (body.action === "duplicate") {
|
||||
return duplicateTemplate(user.id, id);
|
||||
}
|
||||
throw new Error("Unknown action");
|
||||
});
|
||||
}
|
||||
19
apps/web/src/app/api/templates/route.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import { handleApi } from "@/lib/api";
|
||||
import { getTemplates, createTemplate } from "@/lib/services/adventure";
|
||||
import { requireUser } from "@/lib/services/user";
|
||||
import { parseJsonBody, templateCreateSchema } from "@/lib/validation";
|
||||
|
||||
export async function GET() {
|
||||
return handleApi(async () => {
|
||||
const { user } = await requireUser();
|
||||
return getTemplates(user.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handleApi(async () => {
|
||||
const body = await parseJsonBody(request, templateCreateSchema);
|
||||
const { user } = await requireUser();
|
||||
return createTemplate(user.id, body);
|
||||
});
|
||||
}
|
||||
6
apps/web/src/app/api/themes/route.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import { jsonOk } from "@/lib/api";
|
||||
import { THEMES } from "@/themes/registry";
|
||||
|
||||
export async function GET() {
|
||||
return jsonOk({ themes: THEMES });
|
||||
}
|
||||
311
apps/web/src/app/cartographer/page.tsx
Executable file
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
|
||||
interface Exploration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: string;
|
||||
completedNote?: string | null;
|
||||
minutes?: number | null;
|
||||
}
|
||||
|
||||
export default function CartographerPage() {
|
||||
const qc = useQueryClient();
|
||||
const showXpToast = useUiStore((s) => s.showXpToast);
|
||||
const showActionToast = useUiStore((s) => s.showActionToast);
|
||||
const [tab, setTab] = useState<"active" | "history">("active");
|
||||
const [completeId, setCompleteId] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const { data: desk, isLoading: deskLoading, error: deskError } = useQuery({
|
||||
queryKey: ["cartographer-desk"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/cartographer");
|
||||
if (!res.ok) throw new Error("Failed to load map desk");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: explorations = [], isLoading, error } = useQuery({
|
||||
queryKey: ["explorations"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/explorations");
|
||||
if (!res.ok) throw new Error("Failed to load explorations");
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ["explorations-history"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/explorations?history=true");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
enabled: tab === "history",
|
||||
});
|
||||
|
||||
const [generateSource, setGenerateSource] = useState<"ai" | "fallback" | "existing" | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/explorations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Generate failed");
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
return data as {
|
||||
explorations: Exploration[];
|
||||
source?: "ai" | "fallback" | "existing";
|
||||
};
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setGenerateSource(data.source ?? null);
|
||||
qc.invalidateQueries({ queryKey: ["explorations"] });
|
||||
},
|
||||
});
|
||||
|
||||
const generateNotice =
|
||||
generateSource === "existing"
|
||||
? "You still have suggested quests to review — finish or deny them before generating more."
|
||||
: generateSource === "fallback"
|
||||
? "AI unavailable — showing curated offline quest suggestions."
|
||||
: null;
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: async ({ id, action, note }: { id: string; action: string; note?: string }) => {
|
||||
const res = await fetch(`/api/explorations/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, note }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Action failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["explorations"] });
|
||||
qc.invalidateQueries({ queryKey: ["explorations-history"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["cartographer-desk"] });
|
||||
if (vars.action === "complete") showXpToast(100, "Exploration complete");
|
||||
if (data.actionEventId) {
|
||||
showActionToast(`Exploration ${vars.action}`, data.actionEventId);
|
||||
}
|
||||
setCompleteId(null);
|
||||
setNote("");
|
||||
},
|
||||
});
|
||||
|
||||
const suggested = explorations.filter((e: Exploration) => e.status === "suggested");
|
||||
const active = explorations.filter((e: Exploration) => e.status === "active");
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="font-bold text-lg">The Cartographer's Desk</h1>
|
||||
<button
|
||||
className="retro-btn"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={generate.isPending}
|
||||
>
|
||||
{generate.isPending ? "Generating..." : "Generate Quests"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generate.isError && (
|
||||
<p className="text-sm text-[var(--muted-rose)] mb-3">
|
||||
Could not generate quests. Check AI Health in Settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{generateNotice && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">{generateNotice}</p>
|
||||
)}
|
||||
|
||||
{generateSource === "fallback" && !generateNotice && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">
|
||||
AI is offline — showing curated offline quest suggestions.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!deskLoading && desk && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
|
||||
{desk.domains?.map((d: { key: string; label: string; score: number }) => (
|
||||
<div key={d.key} className="retro-window-inset p-2 text-center">
|
||||
<p className="text-xs text-[var(--warm-grey)]">{d.label}</p>
|
||||
<p className="font-bold text-lg">{d.score}</p>
|
||||
<div className="skill-bar-track h-1.5 mt-1">
|
||||
<div className="skill-bar-fill h-full" style={{ width: `${d.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{desk.horizon?.intention && (
|
||||
<p className="text-sm italic mb-4 text-[var(--warm-grey)]">
|
||||
Horizon: {desk.horizon.intention}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{deskError && (
|
||||
<p className="text-sm text-[var(--muted-rose)] mb-3">Could not load life map scores.</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
||||
Optional curiosity quests — pick what interests you this week.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className={`retro-btn ${tab === "active" ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab("active")}
|
||||
>
|
||||
This Week
|
||||
</button>
|
||||
<button
|
||||
className={`retro-btn ${tab === "history" ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab("history")}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "active" && (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<p>Loading map...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-[var(--muted-rose)]">Failed to load explorations.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggested.map((e: Exploration) => (
|
||||
<QuestCard
|
||||
key={e.id}
|
||||
exploration={e}
|
||||
onAccept={() => action.mutate({ id: e.id, action: "accept" })}
|
||||
onDismiss={() => action.mutate({ id: e.id, action: "dismiss" })}
|
||||
/>
|
||||
))}
|
||||
{active.map((e: Exploration) => (
|
||||
<QuestCard
|
||||
key={e.id}
|
||||
exploration={e}
|
||||
active
|
||||
onComplete={() => setCompleteId(e.id)}
|
||||
/>
|
||||
))}
|
||||
{suggested.length === 0 && active.length === 0 && (
|
||||
<p className="text-sm">
|
||||
No explorations this week. Generate some quests — dismissed quests won't block new ones.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "history" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{history.map((e: Exploration) => (
|
||||
<span
|
||||
key={e.id}
|
||||
className="retro-window-inset px-3 py-1 text-xs"
|
||||
title={e.completedNote ?? ""}
|
||||
>
|
||||
✓ {e.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completeId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="retro-window p-4 w-full max-w-md">
|
||||
<p className="font-bold mb-2">What did you learn?</p>
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 mb-4"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="One thing you discovered..."
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary w-full"
|
||||
onClick={() => action.mutate({ id: completeId, action: "complete", note })}
|
||||
>
|
||||
Complete Exploration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestCard({
|
||||
exploration,
|
||||
active,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onComplete,
|
||||
}: {
|
||||
exploration: Exploration;
|
||||
active?: boolean;
|
||||
onAccept?: () => void;
|
||||
onDismiss?: () => void;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="retro-window p-4 relative">
|
||||
<span className="absolute top-2 right-2 text-xs bg-[var(--gold-trim)] px-2 py-0.5 rounded">
|
||||
{exploration.category}
|
||||
</span>
|
||||
<h3 className="font-bold mb-1 pr-20">{exploration.title}</h3>
|
||||
<p className="text-sm mb-3">{exploration.description}</p>
|
||||
{exploration.minutes && (
|
||||
<p className="text-xs text-[var(--warm-grey)] mb-2">~{exploration.minutes} min</p>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{active ? (
|
||||
<>
|
||||
<button className="retro-btn retro-btn-primary" onClick={onComplete}>
|
||||
Complete
|
||||
</button>
|
||||
<Link
|
||||
href={`/teacher?explorationId=${exploration.id}&topic=${encodeURIComponent(exploration.title)}`}
|
||||
className="retro-btn text-xs"
|
||||
>
|
||||
Study with Teacher
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="retro-btn retro-btn-primary" onClick={onAccept}>
|
||||
Accept
|
||||
</button>
|
||||
<button className="retro-btn" onClick={onDismiss}>
|
||||
Deny quest
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Executable file
|
After Width: | Height: | Size: 25 KiB |
215
apps/web/src/app/globals.css
Executable file
@@ -0,0 +1,215 @@
|
||||
@import "tailwindcss";
|
||||
@import "../themes/index.css";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: calc(14px * var(--density));
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-body-bg);
|
||||
background-image: var(--color-body-bg-image);
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: var(--effect-scanlines);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-xp-blue: var(--color-accent);
|
||||
--color-bliss-green: var(--color-positive);
|
||||
--color-parchment: var(--color-surface);
|
||||
--color-gold: var(--color-gold);
|
||||
--color-ink: var(--color-text);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-serif: var(--font-serif);
|
||||
}
|
||||
|
||||
.retro-window {
|
||||
background: var(--color-surface);
|
||||
border: 2px solid;
|
||||
border-color: var(--color-border-light) var(--color-border-dark) var(--color-border-dark)
|
||||
var(--color-border-light);
|
||||
box-shadow: var(--window-shadow);
|
||||
}
|
||||
|
||||
.retro-window-inset {
|
||||
border: 2px solid;
|
||||
border-color: var(--color-border-dark) var(--color-border-light) var(--color-border-light)
|
||||
var(--color-border-dark);
|
||||
background: var(--color-surface-inset);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.retro-titlebar {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--color-titlebar-start) 0%,
|
||||
var(--color-titlebar-mid) 8%,
|
||||
var(--color-titlebar-end) 100%
|
||||
);
|
||||
color: var(--color-accent-text);
|
||||
padding: 4px 8px;
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.retro-btn {
|
||||
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%);
|
||||
border: 2px solid;
|
||||
border-color: var(--color-border-light) var(--color-border-dark) var(--color-border-dark)
|
||||
var(--color-border-light);
|
||||
padding: 4px 12px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retro-btn:hover {
|
||||
background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-btn-hover) 100%);
|
||||
}
|
||||
|
||||
.retro-btn:active {
|
||||
border-color: var(--color-border-dark) var(--color-border-light) var(--color-border-light)
|
||||
var(--color-border-dark);
|
||||
padding: 5px 11px 3px 13px;
|
||||
}
|
||||
|
||||
.retro-btn-primary {
|
||||
background: linear-gradient(180deg, var(--color-btn-primary-start) 0%, var(--color-accent) 100%);
|
||||
color: var(--color-accent-text);
|
||||
border-color: var(--color-btn-primary-border-light) var(--color-btn-primary-border-dark)
|
||||
var(--color-btn-primary-border-dark) var(--color-btn-primary-border-light);
|
||||
}
|
||||
|
||||
.skill-bar-track {
|
||||
height: 14px;
|
||||
background: var(--color-skill-track);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-skill-fill) 80%, white) 0%,
|
||||
var(--color-skill-fill) 50%,
|
||||
color-mix(in srgb, var(--color-skill-fill) 70%, black) 100%
|
||||
);
|
||||
border-right: 1px solid var(--color-gold);
|
||||
transition: width 600ms ease-out;
|
||||
}
|
||||
|
||||
.skill-bar-fill-gold {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-skill-fill-gold) 80%, white) 0%,
|
||||
var(--color-skill-fill-gold) 50%,
|
||||
color-mix(in srgb, var(--color-skill-fill-gold) 60%, black) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skill-bar-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.serif {
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
.xp-toast {
|
||||
animation: slideUp 200ms ease-out, fadeOut 3s ease-in 2s forwards;
|
||||
}
|
||||
|
||||
.action-toast {
|
||||
animation: slideUp 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.book-spine {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
min-height: 120px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 2px 4px 4px 2px;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms;
|
||||
}
|
||||
|
||||
.book-spine:hover {
|
||||
transform: rotate(180deg) translateY(-4px);
|
||||
}
|
||||
|
||||
.parchment-bg {
|
||||
background: var(--color-surface);
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.theme-preview-card {
|
||||
cursor: pointer;
|
||||
transition: transform 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
.theme-preview-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.theme-preview-card.selected {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-preview-bar {
|
||||
height: 20px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.theme-preview-body {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.theme-preview-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
44
apps/web/src/app/layout.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { XpToast, ActionToast, BookCelebration, WelcomeBack } from "@/components/retro/overlays";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AdventureOS",
|
||||
description: "Your personal command centre for consistency and growth",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: { capable: true, title: "AdventureOS" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#3A6EA5",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" data-theme="minimal-dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var L={xp:"windows-xp-light",win98:"windows-98",mac:"gnome-2",terminal:"retro-terminal"};var V=["minimal-dark","retro-terminal","windows-xp-dark","windows-xp-light","windows-98","classic-kde","gnome-2","runescape","game-boy","early-web-forum","crt-hacker","library"];var c=localStorage.getItem("adventureos-theme");var t="minimal-dark";if(c){t=L[c]||(V.indexOf(c)>=0?c:"minimal-dark");}document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
{children}
|
||||
<XpToast />
|
||||
<ActionToast />
|
||||
<BookCelebration />
|
||||
<WelcomeBack />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
425
apps/web/src/app/library/page.tsx
Executable file
@@ -0,0 +1,425 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { useState } from "react";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
|
||||
type CalibreBookRow = {
|
||||
id: number;
|
||||
title: string;
|
||||
authors: string[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
status: string;
|
||||
progressPercent: number;
|
||||
hasCover: boolean;
|
||||
};
|
||||
|
||||
export default function LibraryPage() {
|
||||
const qc = useQueryClient();
|
||||
const showBookCelebration = useUiStore((s) => s.showBookCelebration);
|
||||
const showActionToast = useUiStore((s) => s.showActionToast);
|
||||
const [tab, setTab] = useState<"reading" | "finished">("reading");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newBook, setNewBook] = useState({ title: "", author: "", totalPages: 300 });
|
||||
const [selectedCalibre, setSelectedCalibre] = useState<number | null>(null);
|
||||
const [selectedManual, setSelectedManual] = useState<string | null>(null);
|
||||
const [logPages, setLogPages] = useState(10);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: calibreStatus } = useQuery({
|
||||
queryKey: ["library-status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/library/status");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const useCalibre = calibreStatus?.configured && calibreStatus?.online;
|
||||
|
||||
const { data: calibreBooks = [], isLoading: calibreLoading } = useQuery<CalibreBookRow[]>({
|
||||
queryKey: ["library-books", search],
|
||||
queryFn: async () => {
|
||||
const params = search ? `?search=${encodeURIComponent(search)}` : "";
|
||||
const res = await fetch(`/api/library/books${params}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!useCalibre,
|
||||
});
|
||||
|
||||
const { data: manualBooks = [], isLoading: manualLoading } = useQuery({
|
||||
queryKey: ["books"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/books");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !useCalibre,
|
||||
});
|
||||
|
||||
const addBook = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/books", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newBook),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["books"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
setShowAdd(false);
|
||||
},
|
||||
});
|
||||
|
||||
const logManual = useMutation({
|
||||
mutationFn: async ({ id, pages }: { id: string; pages: number }) => {
|
||||
const res = await fetch(`/api/books/${id}/log-pages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pages }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["books"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
const book = manualBooks.find((b: { id: string; title: string }) => b.id === vars.id);
|
||||
if (data.status === "finished" && book) showBookCelebration(book.title);
|
||||
if (data.actionEventId && book) {
|
||||
showActionToast(`Logged ${vars.pages} pages in ${book.title}`, data.actionEventId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const logCalibre = useMutation({
|
||||
mutationFn: async ({ id, pages }: { id: number; pages: number }) => {
|
||||
const res = await fetch(`/api/library/progress/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pages }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["library-books"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
const book = calibreBooks.find((b) => b.id === vars.id);
|
||||
if (data.status === "finished" && book) showBookCelebration(book.title);
|
||||
if (data.actionEventId && book) {
|
||||
showActionToast(`Logged ${vars.pages} pages in ${book.title}`, data.actionEventId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const books = useCalibre ? calibreBooks : manualBooks;
|
||||
const isLoading = useCalibre ? calibreLoading : manualLoading;
|
||||
|
||||
const filtered = books.filter((b: { status: string }) =>
|
||||
tab === "reading" ? b.status !== "finished" : b.status === "finished"
|
||||
);
|
||||
|
||||
const spineColors = ["#8B4513", "#2F4F4F", "#800020", "#4A3728", "#1B3A5C"];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="font-bold text-lg">The Library Wing</h1>
|
||||
{!useCalibre && (
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => setShowAdd(true)}>
|
||||
Add Book
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{calibreStatus?.configured === false && (
|
||||
<div className="retro-window-inset p-3 mb-4 text-sm">
|
||||
<p className="font-bold mb-1">Calibre not configured</p>
|
||||
<p className="text-[var(--warm-grey)]">
|
||||
Set <code>CALIBRE_LIBRARY_PATH</code> in your environment to connect your Calibre library.
|
||||
Manual book entry is available below.
|
||||
</p>
|
||||
<button className="retro-btn text-xs mt-2" onClick={() => setShowAdd(true)}>
|
||||
Add manual book
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calibreStatus?.configured && !calibreStatus.online && (
|
||||
<div className="retro-window-inset p-3 mb-4 text-sm text-[var(--muted-rose)]">
|
||||
{calibreStatus.error ?? "Calibre library unavailable"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useCalibre && (
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 mb-4 text-sm"
|
||||
placeholder="Search titles or authors..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className={`retro-btn ${tab === "reading" ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab("reading")}
|
||||
>
|
||||
Reading
|
||||
</button>
|
||||
<button
|
||||
className={`retro-btn ${tab === "finished" ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab("finished")}
|
||||
>
|
||||
Completed Shelf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p>Loading shelf...</p>
|
||||
) : (
|
||||
<div
|
||||
className="parchment-bg retro-window-inset p-6 min-h-[200px] flex flex-wrap gap-3 items-end"
|
||||
style={{ background: "linear-gradient(180deg, #8B6914 0%, #6B4F10 100%)" }}
|
||||
>
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-white/80 text-sm w-full text-center py-8">
|
||||
{tab === "reading" ? "No books yet." : "Completed books appear here."}
|
||||
</p>
|
||||
)}
|
||||
{useCalibre
|
||||
? filtered.map((book: CalibreBookRow, i: number) => (
|
||||
<button
|
||||
key={book.id}
|
||||
onClick={() => setSelectedCalibre(book.id)}
|
||||
className="book-spine text-white text-xs font-bold shadow-md relative overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: spineColors[i % spineColors.length],
|
||||
width: 36,
|
||||
minHeight: 120,
|
||||
}}
|
||||
title={book.title}
|
||||
>
|
||||
{book.hasCover && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/library/cover/${book.id}`}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-40"
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{book.title.slice(0, 20)}</span>
|
||||
</button>
|
||||
))
|
||||
: filtered.map(
|
||||
(book: { id: string; title: string }, i: number) => (
|
||||
<button
|
||||
key={book.id}
|
||||
onClick={() => setSelectedManual(book.id)}
|
||||
className="book-spine text-white text-xs font-bold shadow-md"
|
||||
style={{
|
||||
backgroundColor: spineColors[i % spineColors.length],
|
||||
width: 36,
|
||||
}}
|
||||
title={book.title}
|
||||
>
|
||||
{book.title.slice(0, 20)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useCalibre && calibreStatus?.bookCount != null && (
|
||||
<p className="text-xs text-[var(--warm-grey)] mt-2">
|
||||
{calibreStatus.bookCount} books in Calibre catalog
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<Modal onClose={() => setShowAdd(false)} title="Add Book">
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 mb-2"
|
||||
placeholder="Title"
|
||||
value={newBook.title}
|
||||
onChange={(e) => setNewBook({ ...newBook, title: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 mb-2"
|
||||
placeholder="Author"
|
||||
value={newBook.author}
|
||||
onChange={(e) => setNewBook({ ...newBook, author: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="retro-window-inset w-full p-2 mb-4"
|
||||
placeholder="Total pages"
|
||||
value={newBook.totalPages}
|
||||
onChange={(e) => setNewBook({ ...newBook, totalPages: Number(e.target.value) })}
|
||||
/>
|
||||
<button className="retro-btn retro-btn-primary w-full" onClick={() => addBook.mutate()}>
|
||||
Add to Shelf
|
||||
</button>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{selectedCalibre != null && (
|
||||
<CalibreBookDetail
|
||||
book={calibreBooks.find((b) => b.id === selectedCalibre)}
|
||||
logPages={logPages}
|
||||
setLogPages={setLogPages}
|
||||
onLog={() => logCalibre.mutate({ id: selectedCalibre, pages: logPages })}
|
||||
onClose={() => setSelectedCalibre(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedManual && (
|
||||
<ManualBookDetail
|
||||
book={manualBooks.find((b: { id: string }) => b.id === selectedManual)}
|
||||
logPages={logPages}
|
||||
setLogPages={setLogPages}
|
||||
onLog={() => logManual.mutate({ id: selectedManual, pages: logPages })}
|
||||
onClose={() => setSelectedManual(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CalibreBookDetail({
|
||||
book,
|
||||
logPages,
|
||||
setLogPages,
|
||||
onLog,
|
||||
onClose,
|
||||
}: {
|
||||
book?: CalibreBookRow;
|
||||
logPages: number;
|
||||
setLogPages: (n: number) => void;
|
||||
onLog: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!book) return null;
|
||||
const pct = book.progressPercent;
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} title={book.title}>
|
||||
{book.authors.length > 0 && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-2">{book.authors.join(", ")}</p>
|
||||
)}
|
||||
<p className="mb-2">
|
||||
Page {book.currentPage} of {book.totalPages} ({pct}%)
|
||||
</p>
|
||||
<div className="skill-bar-track mb-4">
|
||||
<div className="skill-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{book.status !== "finished" && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="retro-window-inset w-20 p-2"
|
||||
value={logPages}
|
||||
onChange={(e) => setLogPages(Number(e.target.value))}
|
||||
/>
|
||||
<button className="retro-btn retro-btn-primary" onClick={onLog}>
|
||||
Log pages
|
||||
</button>
|
||||
<button
|
||||
className="retro-btn"
|
||||
onClick={() => {
|
||||
setLogPages(10);
|
||||
onLog();
|
||||
}}
|
||||
>
|
||||
+10
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualBookDetail({
|
||||
book,
|
||||
logPages,
|
||||
setLogPages,
|
||||
onLog,
|
||||
onClose,
|
||||
}: {
|
||||
book?: {
|
||||
title: string;
|
||||
author: string | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
status: string;
|
||||
};
|
||||
logPages: number;
|
||||
setLogPages: (n: number) => void;
|
||||
onLog: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!book) return null;
|
||||
const pct = Math.round((book.currentPage / book.totalPages) * 100);
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} title={book.title}>
|
||||
{book.author && <p className="text-sm text-[var(--warm-grey)] mb-2">{book.author}</p>}
|
||||
<p className="mb-2">
|
||||
Page {book.currentPage} of {book.totalPages} ({pct}%)
|
||||
</p>
|
||||
<div className="skill-bar-track mb-4">
|
||||
<div className="skill-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{book.status !== "finished" && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
className="retro-window-inset w-20 p-2"
|
||||
value={logPages}
|
||||
onChange={(e) => setLogPages(Number(e.target.value))}
|
||||
/>
|
||||
<button className="retro-btn retro-btn-primary" onClick={onLog}>
|
||||
Log pages
|
||||
</button>
|
||||
<button
|
||||
className="retro-btn"
|
||||
onClick={() => {
|
||||
setLogPages(10);
|
||||
onLog();
|
||||
}}
|
||||
>
|
||||
+10
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({
|
||||
children,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="retro-window w-full max-w-md p-4">
|
||||
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3 flex justify-between">
|
||||
<span>{title}</span>
|
||||
<button onClick={onClose} className="text-white hover:opacity-80">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/app/login/page.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} else {
|
||||
setError("Invalid password");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<form onSubmit={handleSubmit} className="retro-window w-full max-w-sm p-6">
|
||||
<div className="retro-titlebar -mx-6 -mt-6 mb-6 px-4">AdventureOS</div>
|
||||
<p className="serif text-center mb-4 text-sm">
|
||||
Welcome, traveler. Enter to continue your adventure.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
className="retro-window-inset w-full p-2 mb-4"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-[var(--muted-rose)] text-sm mb-2">{error}</p>}
|
||||
<button type="submit" className="retro-btn retro-btn-primary w-full">
|
||||
Enter Command Centre
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
apps/web/src/app/mentor/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import Link from "next/link";
|
||||
|
||||
const SUGGESTED = [
|
||||
"What should I focus on today?",
|
||||
"Why do I keep falling off my reading?",
|
||||
"Give me a gentle plan for tomorrow.",
|
||||
"What have I been worried about recently?",
|
||||
"What do you know about me?",
|
||||
"What goals am I working towards?",
|
||||
];
|
||||
|
||||
export default function MentorPage() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: sessionsData } = useQuery({
|
||||
queryKey: ["chat-sessions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/chat/sessions");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId && sessionsData?.sessions?.[0]) {
|
||||
setSessionId(sessionsData.sessions[0].id);
|
||||
} else if (!sessionId) {
|
||||
fetch("/api/ai/chat/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setSessionId(d.id));
|
||||
}
|
||||
}, [sessionId, sessionsData]);
|
||||
|
||||
const { data: chatData, refetch } = useQuery({
|
||||
queryKey: ["chat-session", sessionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
|
||||
const { data: knowsData } = useQuery({
|
||||
queryKey: ["ai-knows"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/chat/knows");
|
||||
return res.json();
|
||||
},
|
||||
enabled: tab === "knows",
|
||||
});
|
||||
|
||||
const { data: previewData, refetch: refetchPreview } = useQuery({
|
||||
queryKey: ["context-preview", input],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ message: input || "hello", feature: "mentor" });
|
||||
const res = await fetch(`/api/ai/context/preview?${params}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: tab === "preview",
|
||||
});
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Send failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
setInput("");
|
||||
},
|
||||
});
|
||||
|
||||
const newSession = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/ai/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (d) => setSessionId(d.id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatData?.messages?.length]);
|
||||
|
||||
const messages = chatData?.messages ?? [];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 max-w-4xl mx-auto">
|
||||
<div className="flex flex-wrap gap-2 mb-4 items-center">
|
||||
<h1 className="font-bold text-lg">Mentor</h1>
|
||||
<Link href="/settings?section=ai-memory" className="text-xs underline text-[var(--warm-grey)]">
|
||||
Edit AI Memory →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-4">
|
||||
{(["chat", "knows", "preview"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`retro-btn text-xs ${tab === t ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === "knows" ? "What I know" : t === "preview" ? "Context preview" : "Chat"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "chat" && (
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="retro-window p-2 md:col-span-1 max-h-64 overflow-y-auto">
|
||||
<button type="button" className="retro-btn text-xs w-full mb-2" onClick={() => newSession.mutate()}>
|
||||
New conversation
|
||||
</button>
|
||||
{(sessionsData?.sessions ?? []).map((s: { id: string; title: string }) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`block w-full text-left text-xs p-1 truncate ${sessionId === s.id ? "font-bold" : ""}`}
|
||||
onClick={() => setSessionId(s.id)}
|
||||
>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="retro-window md:col-span-3 flex flex-col min-h-[400px]">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => (
|
||||
<div key={m.id} className={`p-3 rounded text-sm ${m.role === "user" ? "bg-white/50 ml-8" : "bg-[var(--xp-blue)]/10 mr-8"}`}>
|
||||
<span className="text-xs font-bold">{m.role === "user" ? "You" : "Mentor"}</span>
|
||||
{m.metadata?.offline && (
|
||||
<span className="text-[10px] ml-2 opacity-60">(offline/local)</span>
|
||||
)}
|
||||
<p className="mt-1 whitespace-pre-wrap">{m.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div className="p-2 flex flex-wrap gap-1 border-t">
|
||||
{SUGGESTED.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
className="retro-btn text-[10px]"
|
||||
onClick={() => send.mutate(q)}
|
||||
disabled={send.isPending}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-3 flex gap-2 border-t">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-2 text-sm"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())}
|
||||
placeholder="Ask the mentor..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary"
|
||||
disabled={!input.trim() || send.isPending}
|
||||
onClick={() => send.mutate(input.trim())}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "knows" && knowsData && (
|
||||
<div className="retro-window p-4 space-y-4">
|
||||
{knowsData.summary && (
|
||||
<div>
|
||||
<h2 className="font-bold text-sm mb-1">Profile summary</h2>
|
||||
<p className="text-sm whitespace-pre-wrap">{knowsData.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
{knowsData.grouped?.map((g: { category: string; label: string; items: { title: string; content: string }[] }) => (
|
||||
<div key={g.category}>
|
||||
<h3 className="font-bold text-sm">{g.label}</h3>
|
||||
<ul className="text-sm list-disc pl-4">
|
||||
{g.items.map((m) => (
|
||||
<li key={m.title}>{m.title}: {m.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{!knowsData.summary && !knowsData.grouped?.length && (
|
||||
<p className="text-sm italic">No memories saved yet. Add some in Settings → AI Memory.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "preview" && (
|
||||
<div className="retro-window p-4 space-y-3">
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 text-sm"
|
||||
placeholder="Sample message to preview context..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="retro-btn text-xs" onClick={() => refetchPreview()}>
|
||||
Refresh preview
|
||||
</button>
|
||||
<pre className="text-xs whitespace-pre-wrap bg-black/5 p-3 rounded max-h-96 overflow-y-auto">
|
||||
{previewData?.formatted ?? "Loading..."}
|
||||
</pre>
|
||||
<p className="text-[10px] text-[var(--warm-grey)]">
|
||||
~{previewData?.tokenEstimate ?? 0} tokens estimated · {previewData?.memoryIds?.length ?? 0} memories selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/app/page.tsx
Executable file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { CharacterCard } from "@/components/features/character-card";
|
||||
import { TodaysAdventure } from "@/components/features/todays-adventure";
|
||||
import { DailyReflection } from "@/components/features/daily-reflection";
|
||||
import { QuestGiverSidebar, ReadingWidget } from "@/components/features/sidebar-widgets";
|
||||
import { DaySwitcher } from "@/components/features/day-switcher";
|
||||
import { CatchUpCard } from "@/components/features/catch-up-card";
|
||||
import { QuickLogPanel } from "@/components/features/quick-log-panel";
|
||||
import { formatDisplayDate, todayString } from "@/lib/dates-client";
|
||||
|
||||
async function fetchDashboard(date?: string) {
|
||||
const url = date ? `/api/dashboard?date=${date}` : "/api/dashboard";
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Failed to load dashboard");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeDate, setActiveDate] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["dashboard", activeDate],
|
||||
queryFn: () => fetchDashboard(activeDate),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-8 text-center">Loading your adventure...</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-8 text-center text-[var(--muted-rose)]">
|
||||
Could not load dashboard. Is the database running?
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const date = data.today?.date ?? data.dateContext?.logicalToday ?? todayString();
|
||||
const ctx = data.dateContext;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4">
|
||||
{ctx && (
|
||||
<DaySwitcher
|
||||
activeDate={date}
|
||||
logicalToday={ctx.logicalToday}
|
||||
logicalYesterday={ctx.logicalYesterday}
|
||||
isGraceWindow={ctx.isGraceWindow}
|
||||
onSelectDate={setActiveDate}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-4">
|
||||
{formatDisplayDate(date)}
|
||||
</p>
|
||||
{data.catchUpGaps?.length > 0 && (
|
||||
<CatchUpCard gaps={data.catchUpGaps} onSelectDate={setActiveDate} />
|
||||
)}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<div className="lg:col-span-4">
|
||||
<CharacterCard
|
||||
displayName={data.user.displayName}
|
||||
currentTitle={data.user.currentTitle}
|
||||
level={data.progress.level}
|
||||
totalXp={data.progress.totalXp}
|
||||
portraitConfig={data.user.portraitConfig}
|
||||
scores={{
|
||||
consistencyScore: data.progress.consistencyScore,
|
||||
disciplineScore: data.progress.disciplineScore,
|
||||
learningScore: data.progress.learningScore,
|
||||
spiritualScore: data.progress.spiritualScore,
|
||||
healthScore: data.progress.healthScore,
|
||||
readingScore: data.progress.readingScore,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
{data.today && (
|
||||
<TodaysAdventure
|
||||
date={date}
|
||||
chapter={data.progress.currentChapter}
|
||||
journeyDay={data.progress.journeyDay}
|
||||
isRestDay={data.today.isRestDay}
|
||||
isCustomized={data.today.isCustomized}
|
||||
workHoursTarget={data.today.workHoursTarget}
|
||||
dayMode={data.today.dayMode ?? "normal"}
|
||||
isBackfilled={data.today.isBackfilled ?? false}
|
||||
items={data.today.items}
|
||||
todos={data.today.todos}
|
||||
/>
|
||||
)}
|
||||
<QuickLogPanel date={date} />
|
||||
<DailyReflection
|
||||
date={date}
|
||||
initial={data.reflection}
|
||||
dateLabel={formatDisplayDate(date)}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
<QuestGiverSidebar suggestions={data.suggestions} />
|
||||
<ReadingWidget
|
||||
activeBooks={data.reading.activeBooks}
|
||||
streak={data.reading.streak}
|
||||
weeklyPages={data.reading.weeklyPages}
|
||||
weeklyGoal={data.reading.weeklyGoal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/app/review/page.tsx
Executable file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { useState } from "react";
|
||||
import { weekStartString } from "@/lib/dates-client";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
|
||||
export default function ReviewPage() {
|
||||
const weekStart = weekStartString();
|
||||
const qc = useQueryClient();
|
||||
const showXpToast = useUiStore((s) => s.showXpToast);
|
||||
const [intention, setIntention] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { data: review, isLoading } = useQuery({
|
||||
queryKey: ["review", weekStart],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/reviews/${weekStart}`);
|
||||
const data = await res.json();
|
||||
if (!data || data.error) return null;
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/reviews/${weekStart}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate" }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["review", weekStart] });
|
||||
showXpToast(150, "Weekly review opened");
|
||||
},
|
||||
});
|
||||
|
||||
const saveIntention = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/reviews/${weekStart}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intention }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-8 text-center">Turning the page...</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-8 text-center max-w-md mx-auto">
|
||||
<h1 className="font-bold text-lg mb-4">Weekly Review</h1>
|
||||
<p className="serif mb-4">
|
||||
Your weekly chapter is ready to be written. Generate your review to see patterns,
|
||||
progress, and a letter from the Guide.
|
||||
</p>
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => generate.mutate()}>
|
||||
Generate This Week's Review
|
||||
</button>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const content = review.content as Record<string, unknown>;
|
||||
const pages = [
|
||||
{
|
||||
title: "XP Earned",
|
||||
body: (
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-[var(--gold-trim)] mb-2">
|
||||
+{review.xpEarned} XP
|
||||
</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{((content.patterns as string[]) ?? []).map((p, i) => (
|
||||
<li key={i}>· {p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Encouragement",
|
||||
body: <p className="serif">{content.encouragement as string}</p>,
|
||||
},
|
||||
{
|
||||
title: "Mentor's Letter",
|
||||
body: (
|
||||
<div className="serif text-base leading-relaxed whitespace-pre-wrap">
|
||||
{review.mentorLetter}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Next Week",
|
||||
body: (
|
||||
<div>
|
||||
<p className="mb-2 text-sm">{content.focus_suggestion as string}</p>
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 mb-2"
|
||||
placeholder="Your one intention for next week..."
|
||||
value={intention || review.userIntention || ""}
|
||||
onChange={(e) => setIntention(e.target.value)}
|
||||
onBlur={() => saveIntention.mutate()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 min-h-full parchment-bg">
|
||||
<div className="max-w-4xl mx-auto retro-window p-6 md:p-10">
|
||||
<div className="text-center mb-8 border-b border-[var(--warm-grey)] pb-4">
|
||||
<p className="text-xs text-[var(--warm-grey)]">Week of {weekStart}</p>
|
||||
<h1 className="serif text-2xl font-bold">Turning the Page</h1>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[300px]">
|
||||
<h2 className="font-bold mb-4">{pages[page].title}</h2>
|
||||
{pages[page].body}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<button
|
||||
className="retro-btn"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
{pages.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
i === page ? "bg-[var(--xp-blue)]" : "bg-gray-300"
|
||||
}`}
|
||||
onClick={() => setPage(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="retro-btn"
|
||||
disabled={page === pages.length - 1}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
310
apps/web/src/app/settings/page.tsx
Executable file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ThemeGallery } from "@/components/theme/theme-gallery";
|
||||
import { TemplateEditor } from "@/components/features/template-editor";
|
||||
import { AiHealthPanel } from "@/components/settings/ai-health-panel";
|
||||
import { AiConfigPanel } from "@/components/settings/ai-config-panel";
|
||||
import {
|
||||
PromptTemplateEditor,
|
||||
SystemPromptsPanel,
|
||||
} from "@/components/settings/prompt-template-editor";
|
||||
import { ActionHistoryPanel } from "@/components/settings/action-history-panel";
|
||||
import { AiMemoryPanel } from "@/components/settings/ai-memory-panel";
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "profile", label: "Profile" },
|
||||
{ id: "spiritual", label: "Spiritual Labels" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "ai-config", label: "AI Configuration" },
|
||||
{ id: "ai-memory", label: "AI Memory" },
|
||||
{ id: "ai-templates", label: "AI Templates" },
|
||||
{ id: "system-prompts", label: "System Prompts" },
|
||||
{ id: "ai-health", label: "AI Health" },
|
||||
{ id: "action-history", label: "Action History" },
|
||||
{ id: "notifications", label: "Notifications" },
|
||||
{ id: "data", label: "Data Export" },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppShell><div className="p-8">Loading settings...</div></AppShell>}>
|
||||
<SettingsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [section, setSection] = useState("profile");
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const s = searchParams.get("section");
|
||||
if (s && SECTIONS.some((sec) => sec.id === s)) setSection(s);
|
||||
}, [searchParams]);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const [profile, setProfile] = useState({ displayName: "", currentTitle: "" });
|
||||
const [spiritual, setSpiritual] = useState({
|
||||
prayerLabels: [] as string[],
|
||||
litanyLabels: [] as string[],
|
||||
});
|
||||
const [readingGoal, setReadingGoal] = useState(50);
|
||||
const [soundsEnabled, setSoundsEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
setProfile({
|
||||
displayName: data.user.displayName,
|
||||
currentTitle: data.user.currentTitle ?? "",
|
||||
});
|
||||
}
|
||||
if (data?.spiritual) {
|
||||
setSpiritual({
|
||||
prayerLabels: data.spiritual.prayerLabels,
|
||||
litanyLabels: data.spiritual.litanyLabels,
|
||||
});
|
||||
}
|
||||
if (data?.settings) {
|
||||
setReadingGoal((data.settings.weekly_reading_goal as number) ?? 50);
|
||||
setSoundsEnabled((data.settings.sounds_enabled as boolean) ?? false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (body: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 flex flex-col md:flex-row gap-4 min-h-full">
|
||||
<div className="retro-window w-full md:w-48 flex-shrink-0">
|
||||
<div className="retro-titlebar">Control Panel</div>
|
||||
<nav className="p-2 max-h-[70vh] overflow-y-auto">
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm hover:bg-white/40 ${
|
||||
section === s.id ? "bg-white/60 font-bold" : ""
|
||||
}`}
|
||||
onClick={() => setSection(s.id)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="retro-window flex-1 p-4">
|
||||
{section === "profile" && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-bold">Profile</h2>
|
||||
<Field label="Display Name" value={profile.displayName} onChange={(v) => setProfile({ ...profile, displayName: v })} />
|
||||
<Field label="Title" value={profile.currentTitle} onChange={(v) => setProfile({ ...profile, currentTitle: v })} />
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate({ profile })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "spiritual" && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-bold">Spiritual Labels</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Customize your prayer and litany checkboxes.</p>
|
||||
{spiritual.prayerLabels.map((l, i) => (
|
||||
<Field
|
||||
key={`p-${i}`}
|
||||
label={`Prayer ${i + 1}`}
|
||||
value={l}
|
||||
onChange={(v) => {
|
||||
const next = [...spiritual.prayerLabels];
|
||||
next[i] = v;
|
||||
setSpiritual({ ...spiritual, prayerLabels: next });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{spiritual.litanyLabels.map((l, i) => (
|
||||
<Field
|
||||
key={`l-${i}`}
|
||||
label={`Litany ${i + 1}`}
|
||||
value={l}
|
||||
onChange={(v) => {
|
||||
const next = [...spiritual.litanyLabels];
|
||||
next[i] = v;
|
||||
setSpiritual({ ...spiritual, litanyLabels: next });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate({ spiritual })}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "templates" && <TemplateEditor />}
|
||||
{section === "appearance" && <ThemeGallery />}
|
||||
{section === "ai-config" && <AiConfigPanel />}
|
||||
{section === "ai-memory" && (
|
||||
<div>
|
||||
<h2 className="font-bold mb-3">AI Memory</h2>
|
||||
<AiMemoryPanel />
|
||||
</div>
|
||||
)}
|
||||
{section === "ai-templates" && <PromptTemplateEditor />}
|
||||
{section === "system-prompts" && <SystemPromptsPanel />}
|
||||
{section === "ai-health" && <AiHealthPanel />}
|
||||
{section === "action-history" && <ActionHistoryPanel />}
|
||||
|
||||
{section === "notifications" && (
|
||||
<div className="space-y-6">
|
||||
<DayBoundarySettings />
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-bold">Notifications</h2>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundsEnabled}
|
||||
onChange={(e) => setSoundsEnabled(e.target.checked)}
|
||||
/>
|
||||
UI sounds (off by default)
|
||||
</label>
|
||||
<Field
|
||||
label="Weekly reading goal (pages)"
|
||||
value={String(readingGoal)}
|
||||
onChange={(v) => setReadingGoal(Number(v))}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary"
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
settings: {
|
||||
sounds_enabled: soundsEnabled,
|
||||
weekly_reading_goal: readingGoal,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "data" && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-bold">Data Export</h2>
|
||||
<a href="/api/export/json" className="retro-btn retro-btn-primary inline-block" download>
|
||||
Download JSON Backup
|
||||
</a>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Nightly pg_dump backups can be configured via scripts/backup.sh on your server.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-4 border-t">
|
||||
<button
|
||||
className="retro-btn text-sm"
|
||||
onClick={async () => {
|
||||
await fetch("/api/auth/login", { method: "DELETE" });
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DayBoundarySettings() {
|
||||
const [hour, setHour] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/day-boundary")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setHour(d.hour ?? 0);
|
||||
setLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
await fetch("/api/settings/day-boundary", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hour }),
|
||||
});
|
||||
};
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-bold">Day boundary</h2>
|
||||
<p className="text-xs text-[var(--warm-grey)]">
|
||||
When your day rolls over. Useful if you often log after midnight.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Day starts at
|
||||
<select
|
||||
className="retro-window-inset p-1"
|
||||
value={hour}
|
||||
onChange={(e) => setHour(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>Midnight (12am)</option>
|
||||
<option value={1}>1:00am</option>
|
||||
<option value={2}>2:00am</option>
|
||||
<option value={3}>3:00am</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="retro-btn retro-btn-primary text-sm" onClick={save}>
|
||||
Save day boundary
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs font-bold block mb-1">{label}</label>
|
||||
<input
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/app/statistics/page.tsx
Executable file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { useState } from "react";
|
||||
import { SimpleBarChart, SimpleLineChart, HeatmapGrid } from "@/components/charts/simple-charts";
|
||||
|
||||
const TABS = ["overview", "reading", "work", "exercise", "spiritual", "learning"] as const;
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("overview");
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ["stats", "overview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/stats/overview");
|
||||
return res.json();
|
||||
},
|
||||
enabled: tab === "overview",
|
||||
});
|
||||
|
||||
const { data: domain } = useQuery({
|
||||
queryKey: ["stats", tab],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/stats/${tab}?range=30`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: tab !== "overview",
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4">
|
||||
<h1 className="font-bold text-lg mb-4">Statistics Hall</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`retro-btn text-xs capitalize ${tab === t ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<a href="/yearly" className="retro-btn text-xs">
|
||||
Year in Adventure
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="retro-window p-4">
|
||||
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3">Performance Monitor</div>
|
||||
|
||||
{tab === "overview" && overview && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<StatBox label="Total XP" value={overview.totalXp} />
|
||||
<StatBox label="XP This Month" value={overview.xpThisMonth} />
|
||||
<StatBox label="Books Done" value={overview.booksCompleted} />
|
||||
<StatBox label="Pages This Month" value={overview.pagesThisMonth} />
|
||||
</div>
|
||||
<ChartFrame title="Consistency (30 days)">
|
||||
<SimpleLineChart data={overview.consistencyTrend} />
|
||||
</ChartFrame>
|
||||
<ChartFrame title="XP by Category">
|
||||
<SimpleBarChart
|
||||
data={overview.xpByCategory.map((c: { category: string; amount: number }) => ({
|
||||
label: c.category,
|
||||
amount: c.amount,
|
||||
}))}
|
||||
dataKey="amount"
|
||||
color="#3A6EA5"
|
||||
/>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "reading" && domain && (
|
||||
<ChartFrame title="Pages per day">
|
||||
<SimpleBarChart data={domain.days} dataKey="pages" color="#C8A951" />
|
||||
</ChartFrame>
|
||||
)}
|
||||
|
||||
{tab === "work" && domain && (
|
||||
<ChartFrame title="Work hours">
|
||||
<SimpleBarChart data={domain.days} dataKey="hours" color="#3A6EA5" />
|
||||
</ChartFrame>
|
||||
)}
|
||||
|
||||
{tab === "exercise" && domain && (
|
||||
<ChartFrame title="Exercise sessions">
|
||||
<HeatmapGrid data={domain.days?.slice(-28) ?? []} />
|
||||
</ChartFrame>
|
||||
)}
|
||||
|
||||
{tab === "spiritual" && domain && (
|
||||
<ChartFrame title="Prayer checks per day">
|
||||
<SimpleBarChart data={domain.days} dataKey="prayer" color="#3A6EA5" />
|
||||
</ChartFrame>
|
||||
)}
|
||||
|
||||
{tab === "learning" && domain && (
|
||||
<ChartFrame title="Classes attended">
|
||||
<SimpleBarChart data={domain.days} dataKey="classes" color="#74B749" />
|
||||
</ChartFrame>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="retro-window-inset p-3">
|
||||
<p className="text-xs text-[var(--warm-grey)]">{label}</p>
|
||||
<p className="text-xl font-bold">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartFrame({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border-4 border-gray-500 rounded-lg p-2 bg-gray-800">
|
||||
<p className="text-white text-xs mb-2 font-mono">{title}</p>
|
||||
<div className="bg-[#1a1a2e] p-2 rounded">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
apps/web/src/app/teacher/page.tsx
Executable file
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
|
||||
type LessonContent = {
|
||||
title?: string;
|
||||
introduction?: string;
|
||||
objectives?: string[];
|
||||
readingSteps?: string[];
|
||||
reflectionPrompt?: string;
|
||||
flashcards: { front: string; back: string }[];
|
||||
quiz: { question: string; options: string[]; answer: number }[];
|
||||
assignment: string;
|
||||
};
|
||||
|
||||
type Lesson = {
|
||||
id: string;
|
||||
topic: string;
|
||||
content: LessonContent;
|
||||
status: string;
|
||||
completedNote?: string | null;
|
||||
createdAt: string;
|
||||
source?: "ai" | "fallback";
|
||||
fallbackReason?: "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
|
||||
};
|
||||
|
||||
export default function TeacherPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppShell><div className="p-8">Loading...</div></AppShell>}>
|
||||
<TeacherPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function TeacherPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const [topic, setTopic] = useState("");
|
||||
const [explorationId, setExplorationId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [content, setContent] = useState<LessonContent | null>(null);
|
||||
const [contentSource, setContentSource] = useState<"ai" | "fallback" | null>(null);
|
||||
const [fallbackReason, setFallbackReason] = useState<string | null>(null);
|
||||
const [revealedQuiz, setRevealedQuiz] = useState<Record<number, number | null>>({});
|
||||
const [completionNote, setCompletionNote] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const t = searchParams.get("topic");
|
||||
const e = searchParams.get("explorationId");
|
||||
if (t) setTopic(t);
|
||||
if (e) setExplorationId(e);
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: history = [], isLoading } = useQuery<Lesson[]>({
|
||||
queryKey: ["teacher"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/teacher");
|
||||
if (!res.ok) throw new Error("Failed to load lessons");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const loadLesson = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/teacher/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to load lesson");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data: Lesson) => {
|
||||
setSelectedId(data.id);
|
||||
setContent(data.content);
|
||||
setContentSource(data.source ?? null);
|
||||
setFallbackReason(data.fallbackReason ?? null);
|
||||
setTopic(data.topic);
|
||||
setRevealedQuiz({});
|
||||
setCompletionNote(data.completedNote ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/teacher", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
topic,
|
||||
...(explorationId ? { explorationId } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? "Generation failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data: Lesson) => {
|
||||
setContent(data.content);
|
||||
setContentSource(data.source ?? null);
|
||||
setFallbackReason(data.fallbackReason ?? null);
|
||||
setSelectedId(data.id);
|
||||
setRevealedQuiz({});
|
||||
qc.invalidateQueries({ queryKey: ["teacher"] });
|
||||
},
|
||||
});
|
||||
|
||||
const complete = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedId) throw new Error("No lesson selected");
|
||||
const res = await fetch(`/api/teacher/${selectedId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "complete", completedNote: completionNote }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["teacher"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4">
|
||||
<h1 className="font-bold text-lg mb-2">The Teacher</h1>
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-4">
|
||||
Ask the Guide to create flashcards, quizzes, and research assignments on any topic.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-2"
|
||||
placeholder="e.g. How Roman roads were built"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={!topic || generate.isPending}
|
||||
>
|
||||
{generate.isPending ? "Teaching..." : "Teach Me"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generate.isError && (
|
||||
<p className="text-sm text-[var(--muted-rose)] mb-4">
|
||||
{generate.error instanceof Error
|
||||
? generate.error.message
|
||||
: "Could not generate lesson. Check AI Health."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{contentSource === "fallback" && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
||||
{fallbackReason === "timeout"
|
||||
? "AI timed out — your model may be too large for this machine. Try a smaller model (e.g. llama3.2:1b) in Settings or .env."
|
||||
: fallbackReason === "generation_failed"
|
||||
? "AI could not generate a lesson (check that your Ollama model is installed) — showing a basic offline template."
|
||||
: "AI is offline or unavailable — showing a basic offline lesson template."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="space-y-4">
|
||||
{content.title && (
|
||||
<h2 className="font-bold text-base">{content.title}</h2>
|
||||
)}
|
||||
{content.introduction && (
|
||||
<Section title="Introduction">
|
||||
<p className="serif text-sm">{content.introduction}</p>
|
||||
</Section>
|
||||
)}
|
||||
{content.objectives && content.objectives.length > 0 && (
|
||||
<Section title="Learning Objectives">
|
||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||
{content.objectives.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{content.readingSteps && content.readingSteps.length > 0 && (
|
||||
<Section title="Reading & Research">
|
||||
<ol className="text-sm list-decimal pl-5 space-y-1">
|
||||
{content.readingSteps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
<Section title="Flashcards">
|
||||
<div className="grid gap-2">
|
||||
{content.flashcards.map((c, i) => (
|
||||
<Flashcard key={i} front={c.front} back={c.back} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Quiz">
|
||||
{content.quiz.map((q, i) => (
|
||||
<QuizQuestion
|
||||
key={i}
|
||||
question={q.question}
|
||||
options={q.options}
|
||||
answer={q.answer}
|
||||
selected={revealedQuiz[i] ?? null}
|
||||
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
<Section title="Research Assignment">
|
||||
<p className="serif">{content.assignment}</p>
|
||||
{content.reflectionPrompt && (
|
||||
<p className="text-sm italic mt-2 text-[var(--warm-grey)]">
|
||||
Reflection: {content.reflectionPrompt}
|
||||
</p>
|
||||
)}
|
||||
{selectedId && (
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
|
||||
placeholder="What did you learn from this assignment?"
|
||||
value={completionNote}
|
||||
onChange={(e) => setCompletionNote(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary mt-2 text-sm"
|
||||
onClick={() => complete.mutate()}
|
||||
disabled={complete.isPending}
|
||||
>
|
||||
Mark assignment complete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="font-bold mb-2">Past Lessons</h2>
|
||||
{isLoading ? (
|
||||
<p className="text-sm">Loading...</p>
|
||||
) : (
|
||||
<ul className="text-sm space-y-1">
|
||||
{history.map((h) => (
|
||||
<li key={h.id}>
|
||||
<button
|
||||
className={`underline text-left ${selectedId === h.id ? "font-bold" : ""}`}
|
||||
onClick={() => loadLesson.mutate(h.id)}
|
||||
>
|
||||
{h.status === "completed" ? "✓ " : ""}
|
||||
{h.topic}
|
||||
</button>
|
||||
<span className="text-xs text-[var(--warm-grey)] ml-2">
|
||||
{new Date(h.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Flashcard({ front, back }: { front: string; back: string }) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className="retro-window-inset p-3 text-left w-full"
|
||||
onClick={() => setFlipped(!flipped)}
|
||||
>
|
||||
<p className="font-bold text-sm">{flipped ? back : front}</p>
|
||||
<p className="text-xs text-[var(--warm-grey)] mt-1">{flipped ? "Back" : "Tap to flip"}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function QuizQuestion({
|
||||
question,
|
||||
options,
|
||||
answer,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
question: string;
|
||||
options: string[];
|
||||
answer: number;
|
||||
selected: number | null;
|
||||
onSelect: (idx: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="retro-window-inset p-3 mb-2">
|
||||
<p className="font-bold text-sm mb-2">{question}</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{options.map((o, j) => {
|
||||
const picked = selected === j;
|
||||
const showResult = selected !== null;
|
||||
const isCorrect = j === answer;
|
||||
let cls = "";
|
||||
if (showResult && picked && isCorrect) cls = "text-[var(--bliss-green)] font-bold";
|
||||
if (showResult && picked && !isCorrect) cls = "text-[var(--muted-rose)]";
|
||||
if (showResult && !picked && isCorrect) cls = "text-[var(--bliss-green)]";
|
||||
return (
|
||||
<li key={j}>
|
||||
<button className={`text-left ${cls}`} onClick={() => onSelect(j)}>
|
||||
{String.fromCharCode(65 + j)}. {o}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="retro-window p-4">
|
||||
<div className="retro-titlebar -mx-4 -mt-4 mb-4 px-3">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/app/yearly/page.tsx
Executable file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
|
||||
export default function YearlyPage() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ["stats", "overview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/stats/overview");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: achievements } = useQuery({
|
||||
queryKey: ["achievements"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/achievements");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-2">{year}</p>
|
||||
<h1 className="serif text-4xl font-bold mb-6">Your Year in Adventure</h1>
|
||||
|
||||
<div className="retro-window p-8 text-left space-y-6">
|
||||
<ScrollSection title="Total XP" value={overview?.totalXp?.toLocaleString() ?? "—"} />
|
||||
<ScrollSection title="Books Completed" value={overview?.booksCompleted ?? "—"} />
|
||||
<ScrollSection title="Pages Read" value={overview?.totalPagesRead?.toLocaleString() ?? "—"} />
|
||||
<ScrollSection
|
||||
title="Achievements Unlocked"
|
||||
value={achievements?.unlocked?.length ?? 0}
|
||||
/>
|
||||
<p className="serif text-center pt-6 border-t italic">
|
||||
Another year on the long road. The adventure continues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollSection({ title, value }: { title: string; value: string | number }) {
|
||||
return (
|
||||
<div className="flex justify-between items-baseline border-b border-[var(--parchment-dark)] pb-3">
|
||||
<span className="font-bold">{title}</span>
|
||||
<span className="text-2xl text-[var(--gold-trim)]">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/components/charts/simple-charts.tsx
Executable file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label?: string; date?: string; value?: number; amount?: number; hours?: number; pages?: number }[];
|
||||
dataKey: string;
|
||||
max?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function SimpleBarChart({ data, dataKey, max, color = "#74B749" }: BarChartProps) {
|
||||
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0));
|
||||
const peak = max ?? Math.max(...values, 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-0.5 h-[200px] pt-4">
|
||||
{values.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 min-w-[4px] rounded-t-sm transition-all"
|
||||
style={{
|
||||
height: `${(v / peak) * 100}%`,
|
||||
backgroundColor: color,
|
||||
minHeight: v > 0 ? 2 : 0,
|
||||
}}
|
||||
title={`${data[i].date ?? data[i].label ?? i}: ${v}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimpleLineChart({
|
||||
data,
|
||||
dataKey = "value",
|
||||
color = "#3A6EA5",
|
||||
}: {
|
||||
data: { date: string; value: number }[];
|
||||
dataKey?: string;
|
||||
color?: string;
|
||||
}) {
|
||||
const values = data.map((d) => Number((d as Record<string, unknown>)[dataKey] ?? 0));
|
||||
const max = Math.max(...values, 100);
|
||||
const width = 100;
|
||||
const height = 100;
|
||||
const points = values
|
||||
.map((v, i) => {
|
||||
const x = (i / Math.max(values.length - 1, 1)) * width;
|
||||
const y = height - (v / max) * height;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[200px] bg-[#1a1a2e] rounded p-2">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeatmapGrid({ data }: { data: { date: string; done: boolean }[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{data.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
className={`aspect-square rounded-sm ${d.done ? "bg-[var(--bliss-green)]" : "bg-gray-300"}`}
|
||||
title={d.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/components/features/adventure-item-row.tsx
Executable file
@@ -0,0 +1,147 @@
|
||||
import type { DailyAdventureItemData } from "@adventureos/shared";
|
||||
|
||||
export function AdventureItemRow({
|
||||
item,
|
||||
disabled,
|
||||
customize,
|
||||
workTarget,
|
||||
onUpdate,
|
||||
onMetaUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DailyAdventureItemData;
|
||||
disabled: boolean;
|
||||
customize: boolean;
|
||||
workTarget: number;
|
||||
onUpdate: (value?: Record<string, unknown>, state?: string) => void;
|
||||
onMetaUpdate: (updates: { label?: string; enabled?: boolean }) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
if (item.type === "duration") {
|
||||
const hours = (item.value?.hours as number) ?? 0;
|
||||
const target = workTarget;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
{customize ? (
|
||||
<input
|
||||
className="retro-window-inset w-24 p-1 text-sm"
|
||||
value={item.label}
|
||||
onChange={(e) => onMetaUpdate({ label: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-24 text-sm font-medium shrink-0">{item.label}</span>
|
||||
)}
|
||||
<div className="flex-1 skill-bar-track h-3">
|
||||
<div
|
||||
className="skill-bar-fill h-full"
|
||||
style={{ width: `${Math.min(100, (hours / target) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs w-16 text-right">
|
||||
{hours.toFixed(1)} / {target}h
|
||||
</span>
|
||||
<button
|
||||
className="retro-btn text-xs px-2"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = Math.min(target, hours + 0.5);
|
||||
onUpdate({ hours: next });
|
||||
}}
|
||||
>
|
||||
+30m
|
||||
</button>
|
||||
{customize && item.isCustom && (
|
||||
<button className="text-xs text-[var(--muted-rose)]" onClick={onRemove}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{customize && !item.isCustom && (
|
||||
<label className="text-xs flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.enabled}
|
||||
onChange={() => onMetaUpdate({ enabled: !item.enabled })}
|
||||
/>
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "checkbox" || item.type === "timeblock") {
|
||||
const done = item.state === "done";
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={done}
|
||||
disabled={disabled}
|
||||
onChange={() => onUpdate({ done: !done }, done ? "blank" : "done")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
{customize ? (
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-1 text-sm"
|
||||
value={item.label}
|
||||
onChange={(e) => onMetaUpdate({ label: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span className={done ? "line-through opacity-70" : ""}>{item.label}</span>
|
||||
)}
|
||||
{item.config?.scheduledTime && (
|
||||
<span className="text-xs text-[var(--warm-grey)]">
|
||||
{item.config.scheduledTime as string}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{customize && item.isCustom && (
|
||||
<button className="text-xs text-[var(--muted-rose)]" onClick={onRemove}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "reading") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.state === "done" || item.state === "partial"}
|
||||
disabled={disabled}
|
||||
onChange={() =>
|
||||
onUpdate(
|
||||
{ pages: item.state === "blank" ? 10 : 0 },
|
||||
item.state === "blank" ? "partial" : "blank"
|
||||
)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
<span className="text-xs text-[var(--warm-grey)]">
|
||||
{(item.value?.pages as number) ? `${item.value.pages} pages today` : "log in Library"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "note") {
|
||||
return (
|
||||
<div className="py-1">
|
||||
<p className="text-xs font-bold text-[var(--warm-grey)] mb-1">{item.label}</p>
|
||||
<textarea
|
||||
className="retro-window-inset w-full p-2 text-sm min-h-[60px] resize-y"
|
||||
defaultValue={(item.value?.note as string) ?? ""}
|
||||
disabled={disabled}
|
||||
onBlur={(e) => onUpdate({ note: e.target.value })}
|
||||
placeholder="Freeform notes for today..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
42
apps/web/src/components/features/catch-up-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { formatDisplayDate } from "@/lib/dates-client";
|
||||
|
||||
type Gap = { date: string; reason: string };
|
||||
|
||||
interface CatchUpCardProps {
|
||||
gaps: Gap[];
|
||||
onSelectDate: (date: string) => void;
|
||||
}
|
||||
|
||||
const REASON_LABEL: Record<string, string> = {
|
||||
empty: "No adventure logged",
|
||||
incomplete: "Adventure started but empty",
|
||||
no_reflection: "Reflection missing",
|
||||
};
|
||||
|
||||
export function CatchUpCard({ gaps, onSelectDate }: CatchUpCardProps) {
|
||||
if (gaps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="retro-window mb-4">
|
||||
<div className="retro-titlebar">Catch up gently</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs text-[var(--warm-grey)]">
|
||||
A few recent days could use a note — no pressure, just pick one if you like.
|
||||
</p>
|
||||
{gaps.map((g) => (
|
||||
<button
|
||||
key={g.date}
|
||||
type="button"
|
||||
className="retro-btn text-xs w-full text-left flex justify-between"
|
||||
onClick={() => onSelectDate(g.date)}
|
||||
>
|
||||
<span>{formatDisplayDate(g.date)}</span>
|
||||
<span className="opacity-70">{REASON_LABEL[g.reason] ?? g.reason}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/components/features/character-card.tsx
Executable file
@@ -0,0 +1,80 @@
|
||||
import { xpProgressInLevel } from "@adventureos/shared";
|
||||
import { SkillBar } from "@/components/retro/skill-bar";
|
||||
|
||||
interface PortraitConfig {
|
||||
skinTone: string;
|
||||
hairColor: string;
|
||||
clothingColor: string;
|
||||
}
|
||||
|
||||
interface CharacterCardProps {
|
||||
displayName: string;
|
||||
currentTitle: string | null;
|
||||
level: number;
|
||||
totalXp: number;
|
||||
portraitConfig: PortraitConfig;
|
||||
scores: {
|
||||
consistencyScore: number;
|
||||
disciplineScore: number;
|
||||
learningScore: number;
|
||||
spiritualScore: number;
|
||||
healthScore: number;
|
||||
readingScore: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function CharacterCard({
|
||||
displayName,
|
||||
currentTitle,
|
||||
level,
|
||||
totalXp,
|
||||
portraitConfig,
|
||||
scores,
|
||||
}: CharacterCardProps) {
|
||||
const xp = xpProgressInLevel(totalXp);
|
||||
|
||||
return (
|
||||
<div className="retro-window h-full">
|
||||
<div className="retro-titlebar">Character Overview</div>
|
||||
<div className="p-4 flex gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg width="80" height="100" viewBox="0 0 80 100" className="drop-shadow">
|
||||
<ellipse cx="40" cy="28" rx="22" ry="26" fill={portraitConfig.skinTone} />
|
||||
<path
|
||||
d="M18 30 Q40 8 62 30 Q58 50 40 48 Q22 50 18 30"
|
||||
fill={portraitConfig.hairColor}
|
||||
/>
|
||||
<rect x="20" y="52" width="40" height="45" rx="4" fill={portraitConfig.clothingColor} />
|
||||
<polygon
|
||||
points="40,12 48,22 32,22"
|
||||
fill="var(--gold-trim)"
|
||||
stroke="#a08030"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text x="40" y="20" textAnchor="middle" fontSize="8" fill="#2c2416" fontWeight="bold">
|
||||
{level}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-bold text-base truncate">{displayName}</h2>
|
||||
<p className="text-xs text-[var(--gold-trim)] mb-2">
|
||||
{currentTitle ?? "New Adventurer"}
|
||||
</p>
|
||||
<div className="text-xs mb-2">
|
||||
Level {level} · {xp.current}/{xp.needed} XP
|
||||
</div>
|
||||
<div className="skill-bar-track mb-3">
|
||||
<div className="skill-bar-fill skill-bar-fill-gold" style={{ width: `${xp.percent}%` }} />
|
||||
</div>
|
||||
<SkillBar label="Consistency" value={scores.consistencyScore} />
|
||||
<SkillBar label="Discipline" value={scores.disciplineScore} />
|
||||
<SkillBar label="Learning" value={scores.learningScore} />
|
||||
<SkillBar label="Spiritual" value={scores.spiritualScore} />
|
||||
<SkillBar label="Health" value={scores.healthScore} />
|
||||
<SkillBar label="Reading" value={scores.readingScore} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/components/features/checklist-item.tsx
Executable file
@@ -0,0 +1,35 @@
|
||||
export function ChecklistItem({
|
||||
label,
|
||||
checks,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
checks: boolean[];
|
||||
disabled: boolean;
|
||||
onToggle: (checks: boolean[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs mb-1">{label}</p>
|
||||
<div className="flex gap-1">
|
||||
{checks.map((c, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const next = [...checks];
|
||||
next[i] = !next[i];
|
||||
onToggle(next);
|
||||
}}
|
||||
className={`w-7 h-7 retro-window-inset text-xs flex items-center justify-center ${
|
||||
c ? "bg-[var(--bliss-green)] text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{c ? "✓" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
apps/web/src/components/features/daily-reflection.tsx
Executable file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, useState } from "react";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
import { hasMeaningfulReflectionContent } from "@/lib/reflection-utils";
|
||||
|
||||
interface ReflectionProps {
|
||||
date: string;
|
||||
dateLabel?: string;
|
||||
initial?: {
|
||||
wentWell: string;
|
||||
learned: string;
|
||||
improveTomorrow: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type ReflectionForm = {
|
||||
wentWell: string;
|
||||
learned: string;
|
||||
improveTomorrow: string;
|
||||
};
|
||||
|
||||
function formSnapshot(form: ReflectionForm) {
|
||||
return JSON.stringify(form);
|
||||
}
|
||||
|
||||
export function DailyReflection({ date, dateLabel, initial }: ReflectionProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [form, setForm] = useState<ReflectionForm>({
|
||||
wentWell: initial?.wentWell ?? "",
|
||||
learned: initial?.learned ?? "",
|
||||
improveTomorrow: initial?.improveTomorrow ?? "",
|
||||
});
|
||||
const lastSaved = useRef(formSnapshot(form));
|
||||
const qc = useQueryClient();
|
||||
const showXpToast = useUiStore((s) => s.showXpToast);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/reflections/${date}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json() as Promise<ReflectionForm & { xpAwarded?: number }>;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
lastSaved.current = formSnapshot(form);
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
if (data.xpAwarded && data.xpAwarded > 0) {
|
||||
showXpToast(data.xpAwarded, "Reflection saved");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleBlur = () => {
|
||||
if (formSnapshot(form) === lastSaved.current) return;
|
||||
if (!hasMeaningfulReflectionContent(form)) return;
|
||||
save.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="retro-window">
|
||||
<button
|
||||
className="retro-titlebar w-full text-left"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
Daily Reflection {dateLabel ? `· ${dateLabel}` : ""} {open ? "▼" : "▶"}
|
||||
<span className="text-xs font-normal opacity-80">~3 minutes</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="p-4 space-y-3">
|
||||
<Field
|
||||
label="What went well?"
|
||||
value={form.wentWell}
|
||||
onChange={(v) => setForm({ ...form, wentWell: v })}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<Field
|
||||
label="What did I learn?"
|
||||
value={form.learned}
|
||||
onChange={(v) => setForm({ ...form, learned: v })}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<Field
|
||||
label="What should I improve tomorrow?"
|
||||
value={form.improveTomorrow}
|
||||
onChange={(v) => setForm({ ...form, improveTomorrow: v })}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onBlur: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs font-bold block mb-1">{label}</label>
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/features/day-switcher.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { formatDisplayDate } from "@/lib/dates-client";
|
||||
|
||||
interface DaySwitcherProps {
|
||||
activeDate: string;
|
||||
logicalToday: string;
|
||||
logicalYesterday: string;
|
||||
isGraceWindow: boolean;
|
||||
onSelectDate: (date: string) => void;
|
||||
}
|
||||
|
||||
export function DaySwitcher({
|
||||
activeDate,
|
||||
logicalToday,
|
||||
logicalYesterday,
|
||||
isGraceWindow,
|
||||
onSelectDate,
|
||||
}: DaySwitcherProps) {
|
||||
const isYesterday = activeDate === logicalYesterday;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div className="flex gap-1 retro-window p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`retro-btn text-xs ${activeDate === logicalToday ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => onSelectDate(logicalToday)}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`retro-btn text-xs ${isYesterday ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => onSelectDate(logicalYesterday)}
|
||||
>
|
||||
Yesterday
|
||||
</button>
|
||||
</div>
|
||||
{isGraceWindow && activeDate === logicalYesterday && (
|
||||
<span className="text-xs italic text-[var(--xp-blue)]">
|
||||
Still logging yesterday?
|
||||
</span>
|
||||
)}
|
||||
{activeDate !== logicalToday && (
|
||||
<span className="text-xs text-[var(--warm-grey)]">
|
||||
Viewing {formatDisplayDate(activeDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||