58
apps/web/src/app/achievements/page.tsx
Executable file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
apps/web/src/app/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
215
apps/web/src/app/globals.css
Executable file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
|
||||
);
|
||||
}
|
||||
128
apps/web/src/components/features/mentor-panel.tsx
Normal file
128
apps/web/src/components/features/mentor-panel.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 do you know about me?",
|
||||
"What goals am I working towards?",
|
||||
];
|
||||
|
||||
export function MentorPanel({ onClose }: { onClose?: () => void }) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
fetch("/api/ai/chat/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Quick chat" }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setSessionId(d.id));
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ["chat-session", sessionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/ai/chat/sessions/${sessionId}`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
|
||||
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("");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [data?.messages?.length]);
|
||||
|
||||
const messages = data?.messages ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="retro-titlebar flex items-center justify-between">
|
||||
<span>Mentor</span>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/mentor" className="text-xs underline font-normal" onClick={onClose}>
|
||||
Open full
|
||||
</Link>
|
||||
{onClose && (
|
||||
<button type="button" className="text-xs font-normal" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2 text-sm min-h-[200px] max-h-[50vh]">
|
||||
{messages.length === 0 && (
|
||||
<p className="text-xs text-[var(--warm-grey)] italic">
|
||||
Ask anything about your journey. I use your saved memories — nothing hidden.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m: { id: string; role: string; content: string }) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`p-2 rounded ${m.role === "user" ? "bg-white/50 ml-4" : "bg-[var(--xp-blue)]/10 mr-4"}`}
|
||||
>
|
||||
<span className="text-[10px] font-bold block mb-1">{m.role === "user" ? "You" : "Mentor"}</span>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div className="p-2 border-t flex flex-wrap gap-1">
|
||||
{SUGGESTED.slice(0, 3).map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
type="button"
|
||||
className="retro-btn text-[10px]"
|
||||
onClick={() => send.mutate(q)}
|
||||
disabled={!sessionId || send.isPending}
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-2 flex gap-2">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-1 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 text-xs"
|
||||
disabled={!input.trim() || send.isPending}
|
||||
onClick={() => send.mutate(input.trim())}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/components/features/quick-log-panel.tsx
Normal file
115
apps/web/src/components/features/quick-log-panel.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface QuickLogPanelProps {
|
||||
date: string;
|
||||
prayerCount?: number;
|
||||
}
|
||||
|
||||
export function QuickLogPanel({ date, prayerCount = 5 }: QuickLogPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [workMinutes, setWorkMinutes] = useState(30);
|
||||
const [readingPages, setReadingPages] = useState(0);
|
||||
const [exercise, setExercise] = useState(false);
|
||||
const [prayerChecks, setPrayerChecks] = useState(0);
|
||||
const [reflection, setReflection] = useState("");
|
||||
const qc = useQueryClient();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/adventures/${date}/quick-log`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workMinutes: workMinutes > 0 ? workMinutes : undefined,
|
||||
readingPages: readingPages > 0 ? readingPages : undefined,
|
||||
exercise: exercise || undefined,
|
||||
prayerChecks: prayerChecks > 0 ? prayerChecks : undefined,
|
||||
reflection: reflection.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Quick log failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
setReflection("");
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="retro-btn text-xs w-full" onClick={() => setOpen(true)}>
|
||||
Quick log (when tired)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retro-window border border-[var(--warm-grey)]/30">
|
||||
<div className="retro-titlebar text-sm">Quick log</div>
|
||||
<div className="p-3 space-y-2 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
Work +{workMinutes}m
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={480}
|
||||
step={30}
|
||||
value={workMinutes}
|
||||
onChange={(e) => setWorkMinutes(Number(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
Reading pages
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={readingPages}
|
||||
onChange={(e) => setReadingPages(Number(e.target.value))}
|
||||
className="retro-input w-16"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={exercise} onChange={(e) => setExercise(e.target.checked)} />
|
||||
Exercise done
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
Prayers ({prayerChecks}/{prayerCount})
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={prayerCount}
|
||||
value={prayerChecks}
|
||||
onChange={(e) => setPrayerChecks(Number(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="One-line reflection (optional)"
|
||||
value={reflection}
|
||||
onChange={(e) => setReflection(e.target.value)}
|
||||
className="retro-input w-full text-xs"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs flex-1"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? "Saving…" : "Save essentials"}
|
||||
</button>
|
||||
<button type="button" className="retro-btn text-xs" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
apps/web/src/components/features/sidebar-widgets.tsx
Executable file
99
apps/web/src/components/features/sidebar-widgets.tsx
Executable file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface QuestGiverProps {
|
||||
suggestions: Array<{
|
||||
id: string;
|
||||
content: Record<string, unknown>;
|
||||
}>;
|
||||
ollamaAvailable?: boolean;
|
||||
}
|
||||
|
||||
export function QuestGiverSidebar({ suggestions }: QuestGiverProps) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await fetch(`/api/ai/suggestions/${id}/dismiss`, { method: "POST" });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["dashboard"] }),
|
||||
});
|
||||
|
||||
const latest = suggestions[0];
|
||||
const quests =
|
||||
(latest?.content?.quests as Array<{
|
||||
title: string;
|
||||
reason: string;
|
||||
xp_hint: string;
|
||||
}>) ?? [];
|
||||
|
||||
return (
|
||||
<div className="retro-window">
|
||||
<div className="retro-titlebar">The Guide</div>
|
||||
<div className="p-3">
|
||||
{quests.length === 0 ? (
|
||||
<p className="text-xs text-[var(--warm-grey)] italic">
|
||||
The Guide is resting. Check back tomorrow.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{quests.map((q, i) => (
|
||||
<li key={i} className="text-sm border-b border-[var(--parchment-dark)] pb-2">
|
||||
<p className="font-medium">{q.title}</p>
|
||||
<p className="text-xs text-[var(--warm-grey)]">{q.reason}</p>
|
||||
<span className="text-xs text-[var(--gold-trim)]">{q.xp_hint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{latest && (
|
||||
<button
|
||||
className="retro-btn text-xs mt-2 w-full"
|
||||
onClick={() => dismiss.mutate(latest.id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReadingWidgetProps {
|
||||
activeBooks: Array<{ title: string; progressPercent: number }>;
|
||||
streak: { current: number; best: number; isPaused: boolean };
|
||||
weeklyPages: number;
|
||||
weeklyGoal: number;
|
||||
}
|
||||
|
||||
export function ReadingWidget({
|
||||
activeBooks,
|
||||
streak,
|
||||
weeklyPages,
|
||||
weeklyGoal,
|
||||
}: ReadingWidgetProps) {
|
||||
return (
|
||||
<div className="retro-window">
|
||||
<div className="retro-titlebar">Reading</div>
|
||||
<div className="p-3 text-sm">
|
||||
<p className="mb-2">
|
||||
This week: <strong>{weeklyPages}</strong> / {weeklyGoal} pages
|
||||
</p>
|
||||
<div className="skill-bar-track mb-2">
|
||||
<div
|
||||
className="skill-bar-fill"
|
||||
style={{ width: `${Math.min(100, (weeklyPages / weeklyGoal) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--warm-grey)]">
|
||||
Streak: {streak.current} days
|
||||
{streak.isPaused && " (paused)"} · Best: {streak.best}
|
||||
</p>
|
||||
{activeBooks[0] && (
|
||||
<p className="text-xs mt-2 truncate">📖 {activeBooks[0].title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
apps/web/src/components/features/template-editor.tsx
Executable file
313
apps/web/src/components/features/template-editor.tsx
Executable file
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import type { TemplateData, AdventureItemType } from "@adventureos/shared";
|
||||
|
||||
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const ITEM_TYPES: AdventureItemType[] = [
|
||||
"duration",
|
||||
"checkbox",
|
||||
"checklist",
|
||||
"timeblock",
|
||||
"reading",
|
||||
"note",
|
||||
];
|
||||
|
||||
export function TemplateEditor() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery<TemplateData[]>({
|
||||
queryKey: ["templates"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/templates");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const selected = templates.find((t) => t.id === selectedId) ?? templates[0] ?? null;
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["templates"] });
|
||||
|
||||
const createTemplate = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const res = await fetch("/api/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, daysOfWeek: [] }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidate();
|
||||
setSelectedId(data.id);
|
||||
setNewName("");
|
||||
},
|
||||
});
|
||||
|
||||
const updateTemplate = useMutation({
|
||||
mutationFn: async (payload: { id: string; data: Record<string, unknown> }) => {
|
||||
const res = await fetch(`/api/templates/${payload.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload.data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const duplicateTemplate = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/templates/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "duplicate" }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidate();
|
||||
setSelectedId(data.id);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTemplate = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/templates/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setSelectedId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const saveItem = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
templateId: string;
|
||||
item: Record<string, unknown>;
|
||||
}) => {
|
||||
const res = await fetch(`/api/templates/${payload.templateId}/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload.item),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const deleteItem = useMutation({
|
||||
mutationFn: async ({ templateId, itemId }: { templateId: string; itemId: string }) => {
|
||||
const res = await fetch(
|
||||
`/api/templates/${templateId}/items?itemId=${itemId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm">Loading templates...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-bold mb-3">Adventure Templates</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-4">
|
||||
Templates auto-populate your daily quest log. Teaching Day takes priority on Tue/Thu.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`retro-btn text-xs ${selected?.id === t.id ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setSelectedId(t.id)}
|
||||
>
|
||||
{t.name}
|
||||
{t.isSystem && " ★"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-2 text-sm"
|
||||
placeholder="New template name..."
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
disabled={!newName.trim()}
|
||||
onClick={() => createTemplate.mutate(newName.trim())}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="retro-window-inset p-4">
|
||||
<div className="flex flex-wrap gap-2 items-center mb-4">
|
||||
<input
|
||||
className="retro-window-inset p-2 font-bold flex-1 min-w-[200px]"
|
||||
value={selected.name}
|
||||
onChange={(e) =>
|
||||
updateTemplate.mutate({ id: selected.id, data: { name: e.target.value } })
|
||||
}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
onClick={() => duplicateTemplate.mutate(selected.id)}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
{!selected.isSystem && (
|
||||
<button
|
||||
className="retro-btn text-xs text-[var(--muted-rose)]"
|
||||
onClick={() => deleteTemplate.mutate(selected.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs mb-2">Active days:</p>
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{DAY_LABELS.map((label, i) => {
|
||||
const active = selected.daysOfWeek.includes(i);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
className={`retro-btn text-xs px-2 ${active ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => {
|
||||
const next = active
|
||||
? selected.daysOfWeek.filter((d) => d !== i)
|
||||
: [...selected.daysOfWeek, i].sort();
|
||||
updateTemplate.mutate({ id: selected.id, data: { daysOfWeek: next } });
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-bold mb-2">Items</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{selected.items.map((item) => (
|
||||
<div key={item.id} className="flex flex-wrap gap-2 items-center text-sm">
|
||||
<select
|
||||
className="retro-window-inset p-1 text-xs"
|
||||
value={item.type}
|
||||
onChange={(e) =>
|
||||
saveItem.mutate({
|
||||
templateId: selected.id,
|
||||
item: { id: item.id, type: e.target.value, label: item.label, config: item.config, sortOrder: item.sortOrder },
|
||||
})
|
||||
}
|
||||
>
|
||||
{ITEM_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-1 min-w-[120px]"
|
||||
value={item.label}
|
||||
onChange={(e) =>
|
||||
saveItem.mutate({
|
||||
templateId: selected.id,
|
||||
item: { id: item.id, type: item.type, label: e.target.value, config: item.config, sortOrder: item.sortOrder },
|
||||
})
|
||||
}
|
||||
/>
|
||||
{item.type === "duration" && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
className="retro-window-inset w-16 p-1 text-xs"
|
||||
placeholder="hrs"
|
||||
value={(item.config?.targetHours as number) ?? 7.5}
|
||||
onChange={(e) =>
|
||||
saveItem.mutate({
|
||||
templateId: selected.id,
|
||||
item: {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config: { ...item.config, targetHours: Number(e.target.value) },
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.type === "timeblock" && (
|
||||
<input
|
||||
className="retro-window-inset w-20 p-1 text-xs"
|
||||
placeholder="18:00"
|
||||
value={(item.config?.scheduledTime as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
saveItem.mutate({
|
||||
templateId: selected.id,
|
||||
item: {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config: { ...item.config, scheduledTime: e.target.value },
|
||||
sortOrder: item.sortOrder,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="text-xs text-[var(--muted-rose)]"
|
||||
onClick={() => deleteItem.mutate({ templateId: selected.id, itemId: item.id })}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
onClick={() =>
|
||||
saveItem.mutate({
|
||||
templateId: selected.id,
|
||||
item: {
|
||||
type: "checkbox",
|
||||
label: "New item",
|
||||
config: {},
|
||||
sortOrder: selected.items.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Add item
|
||||
</button>
|
||||
|
||||
<div className="mt-4 p-3 bg-[var(--color-surface-muted)] rounded text-xs">
|
||||
<p className="font-bold mb-1">Preview</p>
|
||||
<p className="text-[var(--color-text-muted)]">
|
||||
A typical {selected.daysOfWeek.length > 0 ? DAY_LABELS[selected.daysOfWeek[0]] : "day"} would include:
|
||||
</p>
|
||||
<ul className="mt-1">
|
||||
{selected.items.map((item) => (
|
||||
<li key={item.id}>· {item.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
245
apps/web/src/components/features/todays-adventure.tsx
Executable file
245
apps/web/src/components/features/todays-adventure.tsx
Executable file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import type { DailyAdventureItemData, DailyTodoData, DayMode } from "@adventureos/shared";
|
||||
import { DAY_MODES } from "@adventureos/shared";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAdventureMutations } from "@/hooks/useAdventureMutations";
|
||||
import { AdventureItemRow } from "@/components/features/adventure-item-row";
|
||||
import { ChecklistItem } from "@/components/features/checklist-item";
|
||||
|
||||
interface TodaysAdventureProps {
|
||||
date: string;
|
||||
chapter: string;
|
||||
journeyDay: number;
|
||||
isRestDay: boolean;
|
||||
isCustomized: boolean;
|
||||
workHoursTarget: number | null;
|
||||
dayMode: DayMode;
|
||||
isBackfilled: boolean;
|
||||
items: DailyAdventureItemData[];
|
||||
todos: DailyTodoData[];
|
||||
}
|
||||
|
||||
export function TodaysAdventure({
|
||||
date,
|
||||
chapter,
|
||||
journeyDay,
|
||||
isRestDay,
|
||||
isCustomized,
|
||||
workHoursTarget,
|
||||
dayMode,
|
||||
isBackfilled,
|
||||
items,
|
||||
todos,
|
||||
}: TodaysAdventureProps) {
|
||||
const [customize, setCustomize] = useState(false);
|
||||
const [newTodo, setNewTodo] = useState("");
|
||||
const [newItemLabel, setNewItemLabel] = useState("");
|
||||
const [workTarget, setWorkTarget] = useState(
|
||||
workHoursTarget ?? items.find((i) => i.type === "duration")?.config?.targetHours ?? 7.5
|
||||
);
|
||||
|
||||
const { updateItem, addItem, removeItem, todoMut, restDay, updateWorkHours, updateDayMode } =
|
||||
useAdventureMutations(date, items);
|
||||
|
||||
const checklists = items.filter((i) => i.type === "checklist");
|
||||
const main = items.filter((i) => i.type !== "checklist");
|
||||
|
||||
return (
|
||||
<div className="retro-window">
|
||||
<div className="retro-titlebar">
|
||||
<span>Today's Adventure</span>
|
||||
<span className="text-xs font-normal opacity-90">
|
||||
{chapter} · Day {journeyDay}
|
||||
{isBackfilled && " · Logged later"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3 items-center">
|
||||
<select
|
||||
className="retro-window-inset text-xs p-1"
|
||||
value={dayMode}
|
||||
onChange={(e) => updateDayMode.mutate(e.target.value)}
|
||||
>
|
||||
{Object.entries(DAY_MODES).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{dayMode === "low_energy" && (
|
||||
<span className="text-xs italic text-[var(--xp-blue)]">Low-energy day — lighter expectations</span>
|
||||
)}
|
||||
<button
|
||||
className={`retro-btn text-xs ${customize ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setCustomize(!customize)}
|
||||
>
|
||||
{customize ? "Done customizing" : "Customize Today"}
|
||||
</button>
|
||||
{customize && (
|
||||
<Link href="/settings?section=templates" className="text-xs underline text-[var(--warm-grey)]">
|
||||
Edit weekday template →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customize && (
|
||||
<p className="text-xs italic mb-3 text-[var(--xp-blue)]">
|
||||
Editing today only — templates unchanged
|
||||
{isCustomized && " · This day has custom overrides"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isRestDay && (
|
||||
<p className="text-sm italic mb-3 text-[var(--xp-blue)]">
|
||||
Rest day — your adventure pauses gently today.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{main.map((item) => (
|
||||
<AdventureItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
disabled={isRestDay}
|
||||
customize={customize}
|
||||
workTarget={workHoursTarget ?? (item.config?.targetHours as number) ?? 7.5}
|
||||
onUpdate={(value, state) => updateItem.mutate({ id: item.id, value, state })}
|
||||
onMetaUpdate={(updates) => updateItem.mutate({ id: item.id, ...updates })}
|
||||
onRemove={() => removeItem.mutate(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{customize && (
|
||||
<div className="mt-3 flex gap-2 items-center">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-1 text-sm"
|
||||
placeholder="Add custom item..."
|
||||
value={newItemLabel}
|
||||
onChange={(e) => setNewItemLabel(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
disabled={!newItemLabel.trim()}
|
||||
onClick={() => {
|
||||
addItem.mutate(newItemLabel.trim(), {
|
||||
onSuccess: () => setNewItemLabel(""),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customize && items.some((i) => i.type === "duration" && i.label === "Work") && (
|
||||
<div className="mt-3 flex gap-2 items-center">
|
||||
<span className="text-sm">Work target (hours):</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
className="retro-window-inset w-20 p-1 text-sm"
|
||||
value={workTarget}
|
||||
onChange={(e) => setWorkTarget(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
onClick={() => updateWorkHours.mutate(workTarget)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checklists.length > 0 && (
|
||||
<>
|
||||
<hr className="my-4 border-[var(--warm-grey)] opacity-30" />
|
||||
<p className="text-xs font-bold mb-2 text-[var(--warm-grey)]">Checklists</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{checklists.map((item) => (
|
||||
<ChecklistItem
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
checks={(item.value?.checks as boolean[]) ?? []}
|
||||
disabled={isRestDay}
|
||||
onToggle={(checks) => updateItem.mutate({ id: item.id, value: { checks } })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="my-4 border-[var(--warm-grey)] opacity-30" />
|
||||
<p className="text-xs font-bold mb-2 text-[var(--warm-grey)]">Today's Todos</p>
|
||||
<div className="space-y-1">
|
||||
{todos.map((todo) => (
|
||||
<label key={todo.id} className="flex items-center gap-2 py-0.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.done}
|
||||
disabled={isRestDay}
|
||||
onChange={() =>
|
||||
todoMut.mutate({ action: "update", id: todo.id, done: !todo.done })
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={todo.done ? "line-through opacity-70 text-sm" : "text-sm"}>
|
||||
{todo.label}
|
||||
</span>
|
||||
{customize && (
|
||||
<button
|
||||
className="text-xs text-[var(--muted-rose)] ml-auto"
|
||||
onClick={() => todoMut.mutate({ action: "delete", id: todo.id })}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!isRestDay && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-1 text-sm"
|
||||
placeholder="Add a todo for today..."
|
||||
value={newTodo}
|
||||
onChange={(e) => setNewTodo(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newTodo.trim()) {
|
||||
todoMut.mutate(
|
||||
{ action: "create", label: newTodo.trim() },
|
||||
{ onSuccess: () => setNewTodo("") }
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
disabled={!newTodo.trim()}
|
||||
onClick={() =>
|
||||
todoMut.mutate(
|
||||
{ action: "create", label: newTodo.trim() },
|
||||
{ onSuccess: () => setNewTodo("") }
|
||||
)
|
||||
}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
onClick={() => restDay.mutate()}
|
||||
disabled={isRestDay}
|
||||
>
|
||||
Mark Rest Day
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/layout/app-shell.tsx
Executable file
129
apps/web/src/components/layout/app-shell.tsx
Executable file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
import { MentorPanel } from "@/components/features/mentor-panel";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Command Centre", icon: "⌂" },
|
||||
{ href: "/mentor", label: "Mentor", icon: "🧭", aiRelated: true },
|
||||
{ href: "/library", label: "Library", icon: "📚" },
|
||||
{ href: "/cartographer", label: "Cartographer", icon: "🗺" },
|
||||
{ href: "/statistics", label: "Statistics", icon: "📊" },
|
||||
{ href: "/review", label: "Weekly Review", icon: "📖" },
|
||||
{ href: "/achievements", label: "Achievements", icon: "🏆" },
|
||||
{ href: "/teacher", label: "Teacher", icon: "🎓", aiRelated: true },
|
||||
{ href: "/settings", label: "Settings", icon: "⚙" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const setAiOnline = useUiStore((s) => s.setAiOnline);
|
||||
const aiOnline = useUiStore((s) => s.aiOnline);
|
||||
const [mentorOpen, setMentorOpen] = useState(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["ai-health-status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/health");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.health) {
|
||||
setAiOnline(data.health.status === "online");
|
||||
}
|
||||
}, [data, setAiOnline]);
|
||||
|
||||
const today = new Date().toLocaleDateString("en-GB", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col md:flex-row">
|
||||
<aside className="hidden md:flex flex-col w-[200px] retro-window m-2 mr-0 flex-shrink-0">
|
||||
<div className="retro-titlebar flex items-center gap-2">
|
||||
<span>AdventureOS</span>
|
||||
{aiOnline !== null && (
|
||||
<span
|
||||
className="text-[10px] font-normal ml-auto flex items-center gap-1"
|
||||
title={aiOnline ? "AI online" : "AI offline"}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${
|
||||
aiOnline ? "bg-[var(--color-positive)]" : "bg-[var(--color-text-muted)]"
|
||||
}`}
|
||||
/>
|
||||
AI
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<nav className="p-2 flex-1">
|
||||
{NAV.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2 px-2 py-2 text-sm rounded hover:bg-white/40 ${
|
||||
pathname === item.href ? "bg-white/60 font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.aiRelated && aiOnline === false && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-text-muted)]" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-screen m-2 md:ml-0">
|
||||
<header className="retro-titlebar rounded-t flex items-center justify-between gap-2">
|
||||
<span className="md:hidden font-bold">AdventureOS</span>
|
||||
<span className="hidden md:inline">AdventureOS — Personal Command Centre</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs hidden md:inline"
|
||||
onClick={() => setMentorOpen(!mentorOpen)}
|
||||
>
|
||||
Mentor {mentorOpen ? "▾" : "▸"}
|
||||
</button>
|
||||
<span className="text-xs font-normal">{today}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="retro-window flex-1 rounded-t-none overflow-auto relative">
|
||||
{children}
|
||||
{mentorOpen && (
|
||||
<div className="hidden md:block fixed right-4 top-24 w-80 retro-window z-50 shadow-lg max-h-[calc(100vh-6rem)]">
|
||||
<MentorPanel onClose={() => setMentorOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 retro-window flex justify-around p-1 z-40">
|
||||
{NAV.slice(0, 5).map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center p-2 text-xs min-w-[44px] ${
|
||||
pathname === item.href ? "font-bold" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/components/providers.tsx
Executable file
22
apps/web/src/components/providers.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, refetchOnWindowFocus: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/components/retro/overlays.tsx
Executable file
98
apps/web/src/components/retro/overlays.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useUndoMutation } from "@/hooks/useUndoMutation";
|
||||
|
||||
export function XpToast() {
|
||||
const { xpToast, clearXpToast } = useUiStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!xpToast) return;
|
||||
const t = setTimeout(clearXpToast, 3200);
|
||||
return () => clearTimeout(t);
|
||||
}, [xpToast, clearXpToast]);
|
||||
|
||||
if (!xpToast) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 xp-toast retro-window px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-[var(--color-gold)] font-bold">+{xpToast.amount} XP</span>
|
||||
<span className="text-sm">{xpToast.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionToast() {
|
||||
const { actionToast, clearActionToast } = useUiStore();
|
||||
const undo = useUndoMutation({ onSuccess: () => clearActionToast() });
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionToast) return;
|
||||
const t = setTimeout(clearActionToast, 10000);
|
||||
return () => clearTimeout(t);
|
||||
}, [actionToast, clearActionToast]);
|
||||
|
||||
if (!actionToast) return null;
|
||||
|
||||
function handleUndo() {
|
||||
if (!actionToast) return;
|
||||
undo.mutate(actionToast.actionEventId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-6 z-50 action-toast retro-window px-4 py-2 flex items-center gap-3 max-w-sm">
|
||||
<span className="text-sm flex-1">{actionToast.summary}</span>
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
onClick={handleUndo}
|
||||
disabled={undo.isPending}
|
||||
>
|
||||
{undo.isPending ? "..." : "Undo"}
|
||||
</button>
|
||||
<button className="text-xs opacity-60 hover:opacity-100" onClick={clearActionToast}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookCelebration() {
|
||||
const { bookCelebration, clearBookCelebration } = useUiStore();
|
||||
if (!bookCelebration) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="retro-window max-w-md p-6 text-center">
|
||||
<div className="retro-titlebar mb-4 -mx-6 -mt-6 px-4">Chapter Closed</div>
|
||||
<p className="serif text-lg mb-2">You finished</p>
|
||||
<p className="font-bold text-xl mb-4">{bookCelebration.title}</p>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mb-4">
|
||||
Another volume joins your completed shelf. Well traveled.
|
||||
</p>
|
||||
<button className="retro-btn retro-btn-primary" onClick={clearBookCelebration}>
|
||||
Continue the adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeBack() {
|
||||
const { welcomeBack, setWelcomeBack } = useUiStore();
|
||||
if (!welcomeBack) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="retro-window max-w-lg p-6 text-center">
|
||||
<div className="retro-titlebar mb-4 -mx-6 -mt-6 px-4">Welcome Back, Traveler</div>
|
||||
<p className="serif text-base leading-relaxed mb-4">
|
||||
Your adventure continues. No missed days to review — just today, fresh and open.
|
||||
</p>
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => setWelcomeBack(false)}>
|
||||
Enter Command Centre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/components/retro/skill-bar.tsx
Executable file
22
apps/web/src/components/retro/skill-bar.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
interface SkillBarProps {
|
||||
label: string;
|
||||
value: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkillBar({ label, value, className = "" }: SkillBarProps) {
|
||||
return (
|
||||
<div className={`mb-2 ${className}`}>
|
||||
<div className="flex justify-between text-xs mb-0.5">
|
||||
<span>{label}</span>
|
||||
<span className="text-[var(--warm-grey)]">{value}</span>
|
||||
</div>
|
||||
<div className="skill-bar-track">
|
||||
<div
|
||||
className={`skill-bar-fill ${value >= 80 ? "skill-bar-fill-gold" : ""}`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/components/settings/action-history-panel.tsx
Executable file
67
apps/web/src/components/settings/action-history-panel.tsx
Executable file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
import { useUndoMutation } from "@/hooks/useUndoMutation";
|
||||
|
||||
export function ActionHistoryPanel() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["action-history"],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
actions: {
|
||||
id: string;
|
||||
summary: string;
|
||||
actionType: string;
|
||||
createdAt: string;
|
||||
undoable: boolean;
|
||||
}[];
|
||||
}>("/api/actions/recent"),
|
||||
});
|
||||
|
||||
const undo = useUndoMutation({
|
||||
onSuccess: () => {
|
||||
/* query invalidation handled in hook */
|
||||
},
|
||||
});
|
||||
|
||||
const actions = data?.actions ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold">Action History</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Recent actions you can undo. Actions older than 90 days are automatically pruned.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm">Loading...</p>
|
||||
) : actions.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">No recent actions.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{actions.map((a) => (
|
||||
<li key={a.id} className="retro-window-inset p-2 flex items-center gap-2 text-sm">
|
||||
<div className="flex-1">
|
||||
<p>{a.summary}</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{new Date(a.createdAt).toLocaleString()} · {a.actionType}
|
||||
</p>
|
||||
</div>
|
||||
{a.undoable && (
|
||||
<button
|
||||
className="retro-btn text-xs"
|
||||
onClick={() => undo.mutate(a.id)}
|
||||
disabled={undo.isPending}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
apps/web/src/components/settings/ai-config-panel.tsx
Executable file
194
apps/web/src/components/settings/ai-config-panel.tsx
Executable file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const PERSONALITIES = [
|
||||
{ id: "supportive_mentor", label: "Supportive Mentor" },
|
||||
{ id: "wise_teacher", label: "Wise Teacher" },
|
||||
{ id: "quiet_observer", label: "Quiet Observer" },
|
||||
{ id: "academic_tutor", label: "Academic Tutor" },
|
||||
{ id: "friendly_coach", label: "Friendly Coach" },
|
||||
];
|
||||
|
||||
export function AiConfigPanel() {
|
||||
const qc = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["ai-config"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/config");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const [behavior, setBehavior] = useState({
|
||||
personality: "supportive_mentor",
|
||||
verbosity: "balanced",
|
||||
frequency: "weekly",
|
||||
creativity: 0.7,
|
||||
enabled: true,
|
||||
strictMode: false,
|
||||
});
|
||||
const [provider, setProvider] = useState({
|
||||
type: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
model: "llama3.2:3b",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
timeoutMs: 60000,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.behavior) setBehavior(data.behavior);
|
||||
if (data?.provider) setProvider(data.provider);
|
||||
}, [data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/ai/config", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ behavior, provider }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-config"] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold">AI Configuration</h2>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-bold">Personality & Behavior</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={behavior.enabled}
|
||||
onChange={(e) => setBehavior({ ...behavior, enabled: e.target.checked })}
|
||||
/>
|
||||
AI enabled
|
||||
</label>
|
||||
<Field label="Personality">
|
||||
<select
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={behavior.personality}
|
||||
onChange={(e) => setBehavior({ ...behavior, personality: e.target.value })}
|
||||
>
|
||||
{PERSONALITIES.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Verbosity">
|
||||
<select
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={behavior.verbosity}
|
||||
onChange={(e) => setBehavior({ ...behavior, verbosity: e.target.value })}
|
||||
>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="balanced">Balanced</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Frequency">
|
||||
<select
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={behavior.frequency}
|
||||
onChange={(e) => setBehavior({ ...behavior, frequency: e.target.value })}
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={`Creativity: ${behavior.creativity.toFixed(1)}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={behavior.creativity}
|
||||
onChange={(e) => setBehavior({ ...behavior, creativity: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={behavior.strictMode}
|
||||
onChange={(e) => setBehavior({ ...behavior, strictMode: e.target.checked })}
|
||||
/>
|
||||
Strict mode (surface errors instead of silent fallback)
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-bold">Provider Settings</h3>
|
||||
<Field label="Provider Type">
|
||||
<select
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={provider.type}
|
||||
onChange={(e) => setProvider({ ...provider, type: e.target.value })}
|
||||
>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai_compatible">OpenAI Compatible</option>
|
||||
<option value="llamacpp">llama.cpp Server</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Base URL">
|
||||
<input
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={provider.baseUrl}
|
||||
onChange={(e) => setProvider({ ...provider, baseUrl: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<input
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={provider.model}
|
||||
onChange={(e) => setProvider({ ...provider, model: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max Tokens">
|
||||
<input
|
||||
type="number"
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={provider.maxTokens}
|
||||
onChange={(e) => setProvider({ ...provider, maxTokens: parseInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Timeout (ms)">
|
||||
<input
|
||||
type="number"
|
||||
className="retro-window-inset w-full p-2"
|
||||
value={provider.timeoutMs}
|
||||
onChange={(e) => setProvider({ ...provider, timeoutMs: parseInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
API keys are configured via environment variables only (OPENAI_API_KEY, LLAMACPP_API_KEY).
|
||||
Never stored in the database.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="retro-btn retro-btn-primary"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
{save.isPending ? "Saving..." : "Save AI Configuration"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs font-bold block mb-1">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/settings/ai-health-panel.tsx
Executable file
114
apps/web/src/components/settings/ai-health-panel.tsx
Executable file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AiHealthPanel() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["ai-health"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/health");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const runCheck = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/ai/health", { method: "POST" });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-health"] }),
|
||||
});
|
||||
|
||||
const health = data?.health;
|
||||
const logs = data?.logs ?? [];
|
||||
|
||||
const statusColor =
|
||||
health?.status === "online"
|
||||
? "text-[var(--color-positive)]"
|
||||
: health?.status === "degraded"
|
||||
? "text-[var(--color-gold)]"
|
||||
: "text-[var(--color-danger)]";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold">AI Health</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Check whether your local AI provider is reachable and responding.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm">Loading...</p>
|
||||
) : (
|
||||
<div className="retro-window-inset p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-2xl font-bold uppercase ${statusColor}`}>
|
||||
{health?.status ?? "unknown"}
|
||||
</span>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary text-sm"
|
||||
onClick={() => runCheck.mutate()}
|
||||
disabled={runCheck.isPending}
|
||||
>
|
||||
{runCheck.isPending ? "Checking..." : "Run Health Check"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Stat label="Provider" value={health?.provider ?? "—"} />
|
||||
<Stat label="Model" value={health?.model ?? "—"} />
|
||||
<Stat label="Latency" value={health?.latencyMs != null ? `${health.latencyMs}ms` : "—"} />
|
||||
<Stat label="Base URL" value={health?.baseUrlSafe ?? "—"} />
|
||||
<Stat label="Context Length" value={health?.contextLength?.toString() ?? "—"} />
|
||||
<Stat label="Memory" value={health?.memoryUsageMb != null ? `${health.memoryUsageMb} MB` : "—"} />
|
||||
<Stat label="Last Success" value={formatTime(health?.lastSuccessAt)} />
|
||||
<Stat label="Last Failure" value={formatTime(health?.lastFailureAt)} />
|
||||
</div>
|
||||
|
||||
{health?.lastError && (
|
||||
<div className="text-xs text-[var(--color-danger)] retro-window-inset p-2">
|
||||
<strong>Last error:</strong> {health.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{health?.modelsAvailable?.length > 0 && (
|
||||
<div className="text-xs">
|
||||
<strong>Models available:</strong> {health.modelsAvailable.slice(0, 5).join(", ")}
|
||||
{health.modelsAvailable.length > 5 && ` (+${health.modelsAvailable.length - 5} more)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-sm mb-2">Recent Checks</h3>
|
||||
<ul className="text-xs space-y-1">
|
||||
{logs.map((log: { id: string; status: string; checkedAt: string; latencyMs: number }) => (
|
||||
<li key={log.id} className="flex gap-2">
|
||||
<span>{formatTime(log.checkedAt)}</span>
|
||||
<span>{log.status}</span>
|
||||
<span>{log.latencyMs}ms</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[var(--color-text-muted)] text-xs">{label}</span>
|
||||
<p className="font-mono text-xs truncate">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null | undefined) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
365
apps/web/src/components/settings/ai-memory-panel.tsx
Normal file
365
apps/web/src/components/settings/ai-memory-panel.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||
|
||||
type Memory = {
|
||||
id: string;
|
||||
category: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sourceType: string;
|
||||
enabled: boolean;
|
||||
userVerified: boolean;
|
||||
};
|
||||
|
||||
type Suggestion = {
|
||||
id: string;
|
||||
category: string;
|
||||
title: string;
|
||||
content: string;
|
||||
confidence: string;
|
||||
sourceType: string;
|
||||
};
|
||||
|
||||
export function AiMemoryPanel() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState<"memories" | "suggestions" | "summary" | "learning">("memories");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [newMem, setNewMem] = useState({ category: "likes" as MemoryCategory, title: "", content: "" });
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["ai-memory", filter, category],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) params.set("q", filter);
|
||||
if (category) params.set("category", category);
|
||||
const res = await fetch(`/api/ai/memory?${params}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: learningData } = useQuery({
|
||||
queryKey: ["ai-memory-learning"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/memory/learning");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: suggestionsData } = useQuery({
|
||||
queryKey: ["ai-memory-suggestions"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/ai/memory/suggestions");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const createMem = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/ai/memory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newMem),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-memory"] });
|
||||
setNewMem({ category: "likes", title: "", content: "" });
|
||||
},
|
||||
});
|
||||
|
||||
const saveSummary = useMutation({
|
||||
mutationFn: async (summary: string) => {
|
||||
const res = await fetch("/api/ai/memory/summary", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ summary }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-memory"] }),
|
||||
});
|
||||
|
||||
const saveLearning = useMutation({
|
||||
mutationFn: async (body: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/ai/memory/learning", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-memory-learning"] }),
|
||||
});
|
||||
|
||||
const memories: Memory[] = data?.memories ?? [];
|
||||
const summary = data?.summary?.summary ?? "";
|
||||
const suggestions: Suggestion[] = suggestionsData?.suggestions ?? [];
|
||||
const learning = learningData ?? {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--warm-grey)]">
|
||||
What the AI knows about you — fully editable. Raw logs and reflections stay separate.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["memories", "suggestions", "summary", "learning"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`retro-btn text-xs ${tab === t ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === "suggestions" ? `Suggestions (${suggestions.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "memories" && (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
className="retro-window-inset p-1 text-sm flex-1 min-w-[120px]"
|
||||
placeholder="Search memories..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="retro-window-inset text-sm p-1"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{Object.entries(MEMORY_CATEGORIES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="retro-window p-3 space-y-2">
|
||||
<p className="text-xs font-bold">Add memory manually</p>
|
||||
<select
|
||||
className="retro-window-inset text-sm p-1 w-full"
|
||||
value={newMem.category}
|
||||
onChange={(e) => setNewMem({ ...newMem, category: e.target.value as MemoryCategory })}
|
||||
>
|
||||
{Object.entries(MEMORY_CATEGORIES).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="retro-window-inset p-1 text-sm w-full"
|
||||
placeholder="Title"
|
||||
value={newMem.title}
|
||||
onChange={(e) => setNewMem({ ...newMem, title: e.target.value })}
|
||||
/>
|
||||
<textarea
|
||||
className="retro-window-inset p-1 text-sm w-full"
|
||||
placeholder="What should the AI remember?"
|
||||
rows={2}
|
||||
value={newMem.content}
|
||||
onChange={(e) => setNewMem({ ...newMem, content: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs"
|
||||
disabled={!newMem.title.trim() || !newMem.content.trim()}
|
||||
onClick={() => createMem.mutate()}
|
||||
>
|
||||
Add memory
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{memories.map((m) => (
|
||||
<MemoryRow key={m.id} memory={m} onChange={() => qc.invalidateQueries({ queryKey: ["ai-memory"] })} />
|
||||
))}
|
||||
{memories.length === 0 && (
|
||||
<p className="text-sm italic text-[var(--warm-grey)]">No memories yet. Add one above.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a href="/api/ai/memory/summary" className="retro-btn text-xs" download>
|
||||
Export memories
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs text-[var(--muted-rose)]"
|
||||
onClick={async () => {
|
||||
if (!confirm("Archive all memories? This cannot be undone easily.")) return;
|
||||
await fetch("/api/ai/memory/reset", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["ai-memory"] });
|
||||
}}
|
||||
>
|
||||
Reset all memories
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "suggestions" && (
|
||||
<div className="space-y-2">
|
||||
{suggestions.length === 0 && (
|
||||
<p className="text-sm italic">No pending suggestions. Enable learning to receive them.</p>
|
||||
)}
|
||||
{suggestions.map((s) => (
|
||||
<SuggestionRow key={s.id} suggestion={s} onChange={() => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-memory-suggestions"] });
|
||||
qc.invalidateQueries({ queryKey: ["ai-memory"] });
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "summary" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-[var(--warm-grey)]">
|
||||
AI-generated profile summary (editable). Used as compact context for the mentor.
|
||||
</p>
|
||||
<textarea
|
||||
className="retro-window-inset p-2 text-sm w-full"
|
||||
rows={6}
|
||||
value={summary}
|
||||
onChange={(e) => saveSummary.mutate(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs"
|
||||
onClick={async () => {
|
||||
const res = await fetch("/api/ai/memory/summary/rebuild", { method: "POST" });
|
||||
if (res.ok) qc.invalidateQueries({ queryKey: ["ai-memory"] });
|
||||
}}
|
||||
>
|
||||
Rebuild from memories
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "learning" && (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={learning.learningEnabled ?? false}
|
||||
onChange={(e) => saveLearning.mutate({ learningEnabled: e.target.checked })}
|
||||
/>
|
||||
Enable memory learning (opt-in)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={learning.requireApproval ?? true}
|
||||
onChange={(e) => saveLearning.mutate({ requireApproval: e.target.checked })}
|
||||
/>
|
||||
Require approval before saving
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={learning.suggestAfterReflection ?? true}
|
||||
onChange={(e) => saveLearning.mutate({ suggestAfterReflection: e.target.checked })}
|
||||
/>
|
||||
Suggest after reflections
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={learning.allowSensitiveCategories ?? false}
|
||||
onChange={(e) => saveLearning.mutate({ allowSensitiveCategories: e.target.checked })}
|
||||
/>
|
||||
Allow learning sensitive categories
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryRow({ memory, onChange }: { memory: Memory; onChange: () => void }) {
|
||||
const toggle = async () => {
|
||||
await fetch(`/api/ai/memory/${memory.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !memory.enabled }),
|
||||
});
|
||||
onChange();
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
await fetch(`/api/ai/memory/${memory.id}`, { method: "DELETE" });
|
||||
onChange();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`retro-window p-2 text-sm ${!memory.enabled ? "opacity-50" : ""}`}>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="font-bold">{memory.title}</span>
|
||||
<span className="text-xs text-[var(--warm-grey)]">
|
||||
{MEMORY_CATEGORIES[memory.category as MemoryCategory] ?? memory.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1">{memory.content}</p>
|
||||
<p className="text-[10px] text-[var(--warm-grey)] mt-1">Source: {memory.sourceType}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="button" className="retro-btn text-xs" onClick={toggle}>
|
||||
{memory.enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button type="button" className="retro-btn text-xs" onClick={archive}>Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({ suggestion, onChange }: { suggestion: Suggestion; onChange: () => void }) {
|
||||
return (
|
||||
<div className="retro-window p-2 text-sm border-l-4 border-[var(--xp-blue)]">
|
||||
<p className="font-bold">{suggestion.title}</p>
|
||||
<p className="text-xs mt-1">{suggestion.content}</p>
|
||||
<p className="text-[10px] text-[var(--warm-grey)]">
|
||||
{MEMORY_CATEGORIES[suggestion.category as MemoryCategory]} · confidence {suggestion.confidence}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn retro-btn-primary text-xs"
|
||||
onClick={async () => {
|
||||
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/accept`, { method: "POST" });
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs"
|
||||
onClick={async () => {
|
||||
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/reject`, { method: "POST" });
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="retro-btn text-xs"
|
||||
onClick={async () => {
|
||||
await fetch(`/api/ai/memory/suggestions/${suggestion.id}/ignore`, { method: "POST" });
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
apps/web/src/components/settings/prompt-template-editor.tsx
Executable file
189
apps/web/src/components/settings/prompt-template-editor.tsx
Executable file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { apiFetch, jsonBody } from "@/lib/api-client";
|
||||
|
||||
type AiTemplate = {
|
||||
key: string;
|
||||
name: string;
|
||||
body: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type AiPromptEditorProps = {
|
||||
category: "template" | "system_prompt";
|
||||
title: string;
|
||||
description?: string;
|
||||
showPreview?: boolean;
|
||||
advancedWarning?: boolean;
|
||||
};
|
||||
|
||||
export function AiPromptEditor({
|
||||
category,
|
||||
title,
|
||||
description,
|
||||
showPreview = false,
|
||||
advancedWarning = false,
|
||||
}: AiPromptEditorProps) {
|
||||
const qc = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["ai-templates"],
|
||||
queryFn: () => apiFetch<{ templates: AiTemplate[]; placeholders: { key: string; description: string }[] }>("/api/ai/templates"),
|
||||
});
|
||||
|
||||
const templates = (data?.templates ?? []).filter((t) => t.category === category);
|
||||
const placeholders = data?.placeholders ?? [];
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [body, setBody] = useState("");
|
||||
const [preview, setPreview] = useState("");
|
||||
|
||||
const selected = templates.find((t) => t.key === selectedKey);
|
||||
|
||||
function selectTemplate(t: AiTemplate) {
|
||||
setSelectedKey(t.key);
|
||||
setBody(t.body);
|
||||
setPreview("");
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/ai/templates/${selectedKey}`, {
|
||||
method: "PATCH",
|
||||
...jsonBody({ body }),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ai-templates"] }),
|
||||
});
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ template?: AiTemplate }>(`/api/ai/templates/${selectedKey}?action=reset`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
qc.invalidateQueries({ queryKey: ["ai-templates"] });
|
||||
if (data.template) setBody(data.template.body);
|
||||
},
|
||||
});
|
||||
|
||||
const previewMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ rendered: string }>(`/api/ai/templates/${selectedKey}?action=preview`, {
|
||||
method: "POST",
|
||||
...jsonBody({ body }),
|
||||
}),
|
||||
onSuccess: (data) => setPreview(data.rendered),
|
||||
});
|
||||
|
||||
const resetConfirm =
|
||||
category === "system_prompt"
|
||||
? "Reset to default system prompt?"
|
||||
: "Reset this template to default?";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{advancedWarning && (
|
||||
<div className="retro-window-inset p-3 border-l-4 border-[var(--color-gold)]">
|
||||
<p className="text-sm font-bold">Advanced Settings</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Modifying system prompts affects all AI generation. Proceed carefully.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="font-bold">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="w-full md:w-48 space-y-1">
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm hover:bg-white/40 ${
|
||||
selectedKey === t.key ? "bg-white/60 font-bold" : ""
|
||||
}`}
|
||||
onClick={() => selectTemplate(t)}
|
||||
>
|
||||
{t.name}
|
||||
{t.enabled === false && " (disabled)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="flex-1 space-y-3">
|
||||
{selected.description && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{selected.description}</p>
|
||||
)}
|
||||
<textarea
|
||||
className="retro-window-inset w-full p-2 font-mono text-xs h-48"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button className="retro-btn retro-btn-primary" onClick={() => save.mutate()}>
|
||||
Save
|
||||
</button>
|
||||
{showPreview && (
|
||||
<button className="retro-btn" onClick={() => previewMut.mutate()}>
|
||||
Preview
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="retro-btn"
|
||||
onClick={() => {
|
||||
if (confirm(resetConfirm)) reset.mutate();
|
||||
}}
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
{preview && (
|
||||
<pre className="retro-window-inset p-2 text-xs whitespace-pre-wrap max-h-40 overflow-auto">
|
||||
{preview}
|
||||
</pre>
|
||||
)}
|
||||
{showPreview && placeholders.length > 0 && (
|
||||
<div className="text-xs">
|
||||
<strong>Placeholders:</strong>
|
||||
<ul className="mt-1">
|
||||
{placeholders.map((p) => (
|
||||
<li key={p.key}>
|
||||
<code>{`{{${p.key}}}`}</code> — {p.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptTemplateEditor() {
|
||||
return (
|
||||
<AiPromptEditor
|
||||
category="template"
|
||||
title="AI Templates"
|
||||
description='Edit the prompts that control AI behavior. Use {"{{placeholders}}"} for dynamic content.'
|
||||
showPreview
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemPromptsPanel() {
|
||||
return (
|
||||
<AiPromptEditor
|
||||
category="system_prompt"
|
||||
title="System Prompts"
|
||||
advancedWarning
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/theme/theme-gallery.tsx
Executable file
66
apps/web/src/components/theme/theme-gallery.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { THEMES, type ThemeId } from "@/themes/registry";
|
||||
import { useTheme } from "./theme-provider";
|
||||
|
||||
export function ThemeGallery() {
|
||||
const { activeTheme, previewTheme, setPreviewTheme, applyTheme, theme } = useTheme();
|
||||
const isPreviewing = previewTheme !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold">Appearance</h2>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Choose a nostalgia theme. Click to preview, then apply.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`theme-preview-card retro-window text-left overflow-hidden ${
|
||||
activeTheme === t.id ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => setPreviewTheme(t.id)}
|
||||
>
|
||||
<div
|
||||
className="theme-preview-bar"
|
||||
style={{ background: t.swatches[1] }}
|
||||
/>
|
||||
<div className="theme-preview-body" style={{ background: t.swatches[0] }}>
|
||||
{t.swatches.map((c, i) => (
|
||||
<div key={i} className="theme-preview-swatch" style={{ background: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="px-2 pb-2">
|
||||
<p className="text-xs font-bold">{t.name}</p>
|
||||
<p className="text-[10px] text-[var(--color-text-muted)]">{t.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isPreviewing && (
|
||||
<div className="flex gap-2 items-center retro-window-inset p-3">
|
||||
<span className="text-sm flex-1">Previewing: {THEMES.find((t) => t.id === previewTheme)?.name}</span>
|
||||
<button
|
||||
className="retro-btn"
|
||||
onClick={() => setPreviewTheme(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary"
|
||||
onClick={() => applyTheme(previewTheme as ThemeId)}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isPreviewing && theme && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Current theme: {THEMES.find((t) => t.id === theme)?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/components/theme/theme-provider.tsx
Executable file
88
apps/web/src/components/theme/theme-provider.tsx
Executable file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_THEME,
|
||||
migrateThemeId,
|
||||
type ThemeId,
|
||||
} from "@/themes/registry";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeId;
|
||||
previewTheme: ThemeId | null;
|
||||
setPreviewTheme: (id: ThemeId | null) => void;
|
||||
applyTheme: (id: ThemeId) => Promise<void>;
|
||||
activeTheme: ThemeId;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function applyThemeToDom(id: ThemeId) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.setAttribute("data-theme", id);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<ThemeId>(DEFAULT_THEME);
|
||||
const [previewTheme, setPreviewThemeState] = useState<ThemeId | null>(null);
|
||||
|
||||
const activeTheme = previewTheme ?? theme;
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeToDom(activeTheme);
|
||||
}, [activeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = localStorage.getItem("adventureos-theme");
|
||||
if (cached) {
|
||||
const migrated = migrateThemeId(cached);
|
||||
setTheme(migrated);
|
||||
}
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const stored = data?.settings?.theme as string | undefined;
|
||||
const migrated = migrateThemeId(stored);
|
||||
setTheme(migrated);
|
||||
localStorage.setItem("adventureos-theme", migrated);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setPreviewTheme = useCallback((id: ThemeId | null) => {
|
||||
setPreviewThemeState(id);
|
||||
}, []);
|
||||
|
||||
const applyTheme = useCallback(async (id: ThemeId) => {
|
||||
setPreviewThemeState(null);
|
||||
setTheme(id);
|
||||
localStorage.setItem("adventureos-theme", id);
|
||||
applyThemeToDom(id);
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: { theme: id } }),
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, previewTheme, setPreviewTheme, applyTheme, activeTheme }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
7
apps/web/src/components/ui/page-states.tsx
Executable file
7
apps/web/src/components/ui/page-states.tsx
Executable file
@@ -0,0 +1,7 @@
|
||||
export function LoadingState({ message = "Loading..." }: { message?: string }) {
|
||||
return <div className="p-8 text-center text-[var(--color-text-muted)]">{message}</div>;
|
||||
}
|
||||
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
return <div className="p-8 text-center text-red-600">{message}</div>;
|
||||
}
|
||||
16
apps/web/src/components/ui/retro-window.tsx
Executable file
16
apps/web/src/components/ui/retro-window.tsx
Executable file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type RetroWindowProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function RetroWindow({ title, children, className = "" }: RetroWindowProps) {
|
||||
return (
|
||||
<div className={`retro-window ${className}`}>
|
||||
<div className="retro-titlebar">{title}</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/components/ui/tab-toggle.tsx
Executable file
21
apps/web/src/components/ui/tab-toggle.tsx
Executable file
@@ -0,0 +1,21 @@
|
||||
type TabToggleProps<T extends string> = {
|
||||
tabs: { id: T; label: string }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
};
|
||||
|
||||
export function TabToggle<T extends string>({ tabs, active, onChange }: TabToggleProps<T>) {
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`retro-btn ${active === tab.id ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => onChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/features/adventure/api.ts
Executable file
67
apps/web/src/features/adventure/api.ts
Executable file
@@ -0,0 +1,67 @@
|
||||
import { apiFetch, jsonBody } from "@/lib/api-client";
|
||||
|
||||
export type ActionMutationResult = { actionEventId?: string };
|
||||
|
||||
export function updateAdventureItem(
|
||||
date: string,
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
) {
|
||||
return apiFetch<ActionMutationResult & Record<string, unknown>>(
|
||||
`/api/adventures/${date}/items/${id}`,
|
||||
{ method: "PATCH", ...jsonBody(body) }
|
||||
);
|
||||
}
|
||||
|
||||
export function createAdventureItem(date: string, label: string) {
|
||||
return apiFetch<ActionMutationResult & { item?: unknown }>(
|
||||
`/api/adventures/${date}/items`,
|
||||
{ method: "POST", ...jsonBody({ label }) }
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAdventureItem(date: string, id: string) {
|
||||
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/items/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function createDailyTodo(date: string, label: string) {
|
||||
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/todos`, {
|
||||
method: "POST",
|
||||
...jsonBody({ label }),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDailyTodo(
|
||||
date: string,
|
||||
id: string,
|
||||
body: { label?: string; done?: boolean }
|
||||
) {
|
||||
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/todos/${id}`, {
|
||||
method: "PATCH",
|
||||
...jsonBody(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDailyTodo(date: string, id: string) {
|
||||
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/todos/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function setRestDay(date: string) {
|
||||
return apiFetch<ActionMutationResult>(`/api/adventures/${date}/rest-day`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDaySettings(
|
||||
date: string,
|
||||
body: { workHoursTarget?: number; dayMode?: string }
|
||||
) {
|
||||
return apiFetch<ActionMutationResult>(`/api/adventures/${date}`, {
|
||||
method: "PATCH",
|
||||
...jsonBody(body),
|
||||
});
|
||||
}
|
||||
8
apps/web/src/features/undo/api.ts
Executable file
8
apps/web/src/features/undo/api.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
export function undoAction(actionEventId: string) {
|
||||
return apiFetch<{ ok: boolean; error?: string; summary?: string }>(
|
||||
`/api/actions/${actionEventId}/undo`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user