Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
226
app/api/generate/route.ts
Normal file
226
app/api/generate/route.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { extractNotes, generateActivities, notesToText, type PipelineStage } from "@/lib/ai";
|
||||
import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
|
||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { difficultySchema } from "@/lib/schemas";
|
||||
import { uid } from "@/lib/utils";
|
||||
import { xpForActivity } from "@/lib/xp";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
/** ~8 MB per image after client-side resizing; up to 16 images (photos + PDF pages). */
|
||||
const MAX_IMAGE_CHARS = 8_000_000;
|
||||
|
||||
const requestSchema = z.object({
|
||||
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
difficulty: difficultySchema.default("medium"),
|
||||
count: z.number().int().min(4).max(18).optional(),
|
||||
focus: z.string().max(300).optional(),
|
||||
/** Language the deck must be written in (usually detected by the vision step). */
|
||||
language: z.string().max(60).optional(),
|
||||
mode: z.enum(["create", "more"]).default("create"),
|
||||
existing: z
|
||||
.object({
|
||||
title: z.string().max(120),
|
||||
types: z.array(z.string()).max(30),
|
||||
count: z.number().int().min(0).max(100),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type StreamEvent =
|
||||
| { type: "stage"; stage: PipelineStage; message: string }
|
||||
| { type: "deck"; deck: unknown; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
|
||||
function encoder() {
|
||||
const enc = new TextEncoder();
|
||||
return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
|
||||
/** Generous but finite: ~16 compressed images plus JSON overhead. */
|
||||
const MAX_BODY_BYTES = 33554432;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const crossOrigin = rejectCrossOrigin(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
||||
if (oversized) return oversized;
|
||||
|
||||
const config = getServerConfig();
|
||||
|
||||
// Cheap requests are rejected before any work happens.
|
||||
const limit = rateLimit(`generate:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs);
|
||||
if (!limit.allowed) return rateLimitResponse(limit);
|
||||
|
||||
const auth = resolveAuth(config, request);
|
||||
if ("error" in auth) {
|
||||
return Response.json(
|
||||
{ error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Request body was not valid JSON." }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Invalid request: ${parsed.error.issues
|
||||
.slice(0, 3)
|
||||
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
|
||||
.join("; ")}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { images, text, difficulty, count, focus, mode, existing, language } = parsed.data;
|
||||
const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean);
|
||||
|
||||
if (imageList.length === 0 && !text?.trim()) {
|
||||
return Response.json(
|
||||
{ error: "Add at least one photo of your notes or paste some text to build a deck." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const requestedCount = count ?? config.defaultActivityCount;
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (event: StreamEvent) => {
|
||||
try {
|
||||
controller.enqueue(encode(event));
|
||||
} catch {
|
||||
/* client went away */
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
let sourceText: string;
|
||||
let deckTitle: string | undefined;
|
||||
let subject: string | undefined;
|
||||
let summary: string | undefined;
|
||||
let detectedLanguage: string | undefined = language;
|
||||
let repaired = false;
|
||||
|
||||
if (imageList.length > 0) {
|
||||
send({ type: "stage", stage: "vision", message: "Reading your notes…" });
|
||||
const extraction = await extractNotes({
|
||||
model: config.visionModel,
|
||||
repairModel: config.textModel,
|
||||
auth: auth.auth,
|
||||
images: imageList,
|
||||
text,
|
||||
signal: abort.signal,
|
||||
onProgress: (message) => send({ type: "stage", stage: "vision", message }),
|
||||
});
|
||||
sourceText = notesToText(extraction.notes);
|
||||
deckTitle = extraction.notes.title;
|
||||
subject = extraction.notes.subject;
|
||||
summary = extraction.notes.summary;
|
||||
detectedLanguage = extraction.notes.language || language;
|
||||
repaired = repaired || extraction.repaired || extraction.languageRepaired;
|
||||
send({
|
||||
type: "stage",
|
||||
stage: "generation",
|
||||
message: "Finding the key ideas…",
|
||||
});
|
||||
} else {
|
||||
sourceText = text!.trim();
|
||||
send({ type: "stage", stage: "generation", message: "Finding the key ideas…" });
|
||||
}
|
||||
|
||||
const generated = await generateActivities({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
notes: sourceText,
|
||||
difficulty,
|
||||
count: requestedCount,
|
||||
language: detectedLanguage,
|
||||
existing: mode === "more" ? existing : undefined,
|
||||
focus,
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
const activities = generated.deck.activities.map((activity) => ({
|
||||
...activity,
|
||||
id: uid("act"),
|
||||
xp: xpForActivity(activity.type, difficulty),
|
||||
}));
|
||||
|
||||
send({
|
||||
type: "deck",
|
||||
deck: {
|
||||
title: generated.deck.title || deckTitle || "Your study deck",
|
||||
subject: generated.deck.subject ?? subject,
|
||||
summary: generated.deck.summary ?? summary,
|
||||
language: generated.deck.language || detectedLanguage,
|
||||
sourceText,
|
||||
difficulty,
|
||||
activities,
|
||||
},
|
||||
meta: {
|
||||
requested: generated.requested,
|
||||
generated: activities.length,
|
||||
repaired,
|
||||
language: generated.deck.language || detectedLanguage || null,
|
||||
visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null,
|
||||
textModel: resolveModelName(config.textModel, auth.auth.directCloud),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = friendlyErrorMessage(error, auth.auth.baseUrl);
|
||||
// Never log the request body: it may contain the user's notes.
|
||||
console.error("[api/generate]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
||||
send({
|
||||
type: "error",
|
||||
error: message,
|
||||
kind: error instanceof OllamaError ? error.kind : "unknown",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
clearTimeout(globalTimer);
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-ndjson; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stripDataUrlPrefix(value: string): string {
|
||||
const commaIndex = value.indexOf(",");
|
||||
if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1);
|
||||
return value;
|
||||
}
|
||||
71
app/api/grade/route.ts
Normal file
71
app/api/grade/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { gradeShortAnswer } from "@/lib/ai";
|
||||
import { AUTH_ERROR_MESSAGES, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config";
|
||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestSchema = z.object({
|
||||
question: z.string().min(1).max(1000),
|
||||
referenceAnswer: z.string().min(1).max(2000),
|
||||
keyPoints: z.array(z.string().max(300)).max(8).default([]),
|
||||
userAnswer: z.string().min(1).max(2000),
|
||||
/** Language of the deck so feedback comes back in the same language. */
|
||||
language: z.string().max(60).optional(),
|
||||
});
|
||||
|
||||
/** Generous but finite: ~16 compressed images plus JSON overhead. */
|
||||
const MAX_BODY_BYTES = 65536;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const crossOrigin = rejectCrossOrigin(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
||||
if (oversized) return oversized;
|
||||
|
||||
const config = getServerConfig();
|
||||
|
||||
const limit = rateLimit(`grade:${clientKey(request)}`, config.rateLimits.grade, config.rateLimits.windowMs);
|
||||
if (!limit.allowed) return rateLimitResponse(limit);
|
||||
|
||||
const auth = resolveAuth(config, request);
|
||||
if ("error" in auth) {
|
||||
return Response.json(
|
||||
{ error: AUTH_ERROR_MESSAGES[auth.error], kind: authErrorKind(auth.error) },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Request body was not valid JSON." }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: "Invalid grading request." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await gradeShortAnswer({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
...parsed.data,
|
||||
// Grading is quick; don't let a hung request block the feed for minutes.
|
||||
signal: AbortSignal.timeout(Math.min(config.timeoutMs, 120_000)),
|
||||
});
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
console.error("[api/grade]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
||||
return Response.json(
|
||||
{ error: friendlyErrorMessage(error, auth.auth.baseUrl), kind: error instanceof OllamaError ? error.kind : "unknown" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
131
app/api/health/route.ts
Normal file
131
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { AUTH_ERROR_MESSAGES, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName, type AuthMode } from "@/lib/config";
|
||||
import { friendlyErrorMessage, ollamaListModels, ollamaVerifyKey, OllamaError } from "@/lib/ollama";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export type ModelHealth = {
|
||||
configured: string;
|
||||
resolved: string;
|
||||
available: boolean | null;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type HealthResponse = {
|
||||
ok: boolean;
|
||||
reachable: boolean;
|
||||
mode: "local-daemon" | "direct-cloud";
|
||||
authMode: AuthMode;
|
||||
/** True when the browser has to provide its own key and hasn't. */
|
||||
needsKey: boolean;
|
||||
/** null = not checked (no key), true/false = Ollama accepted/rejected the key. */
|
||||
keyAccepted: boolean | null;
|
||||
baseUrl: string;
|
||||
hasApiKey: boolean;
|
||||
vision: ModelHealth;
|
||||
text: ModelHealth;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
error: string | null;
|
||||
kind?: string;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const config = getServerConfig();
|
||||
|
||||
const limit = rateLimit(`health:${clientKey(request)}`, config.rateLimits.health, config.rateLimits.windowMs);
|
||||
if (!limit.allowed) return rateLimitResponse(limit);
|
||||
|
||||
const started = Date.now();
|
||||
const resolvedAuth = resolveAuth(config, request);
|
||||
|
||||
const health: HealthResponse = {
|
||||
ok: false,
|
||||
reachable: false,
|
||||
mode: config.directCloud ? "direct-cloud" : "local-daemon",
|
||||
authMode: config.authMode,
|
||||
needsKey: "error" in resolvedAuth,
|
||||
keyAccepted: null,
|
||||
baseUrl: config.baseUrl,
|
||||
hasApiKey: !("error" in resolvedAuth) && Boolean(resolvedAuth.auth.apiKey),
|
||||
vision: {
|
||||
configured: config.visionModel,
|
||||
resolved: resolveModelName(config.visionModel, config.directCloud),
|
||||
available: null,
|
||||
},
|
||||
text: {
|
||||
configured: config.textModel,
|
||||
resolved: resolveModelName(config.textModel, config.directCloud),
|
||||
available: null,
|
||||
},
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
error: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if ("error" in resolvedAuth) {
|
||||
health.error =
|
||||
resolvedAuth.error === "missing_key"
|
||||
? "No Ollama API key yet. Add your key in the settings dialog — it is stored only in this browser."
|
||||
: AUTH_ERROR_MESSAGES[resolvedAuth.error];
|
||||
health.kind = authErrorKind(resolvedAuth.error);
|
||||
return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
try {
|
||||
const models = await ollamaListModels(resolvedAuth.auth);
|
||||
health.reachable = true;
|
||||
health.latencyMs = Date.now() - started;
|
||||
health.modelCount = models.length;
|
||||
|
||||
// A key is only "accepted" once Ollama authenticated a real request —
|
||||
// model listings are public, so an actual (tiny, cached) chat proves it.
|
||||
if (resolvedAuth.auth.apiKey) {
|
||||
const probe = await ollamaVerifyKey(resolvedAuth.auth, config.textModel, config.healthTimeoutMs * 3);
|
||||
health.keyAccepted = probe.valid;
|
||||
if (probe.valid === false) {
|
||||
health.error = "Ollama rejected this API key. Create a new one at ollama.com/settings/keys and paste it again.";
|
||||
health.kind = "auth";
|
||||
return Response.json(health satisfies HealthResponse, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
if (probe.reason === "no_credits") {
|
||||
health.error = `The key works, but ${config.textModel} is not included in this account's plan. Add usage credits or switch to a free-tier model in .env.local.`;
|
||||
health.kind = "no_credits";
|
||||
}
|
||||
}
|
||||
|
||||
const names = new Set(models.map((model) => canonicalModelName(model.name)));
|
||||
for (const key of ["vision", "text"] as const) {
|
||||
const entry = health[key];
|
||||
const wanted = canonicalModelName(entry.resolved);
|
||||
const listed = names.has(wanted);
|
||||
entry.available = listed;
|
||||
if (!listed) {
|
||||
entry.note =
|
||||
health.mode === "local-daemon"
|
||||
? `Not in the daemon's model list. Run \`ollama pull ${entry.resolved}\` or check your sign-in.`
|
||||
: `Not returned by ${config.baseUrl}/api/tags. Check the model name and your key's plan.`;
|
||||
}
|
||||
}
|
||||
|
||||
health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false);
|
||||
if (!health.ok && !health.error) {
|
||||
health.error = "Ollama is reachable, but at least one configured model is not available.";
|
||||
}
|
||||
} catch (error) {
|
||||
health.error = friendlyErrorMessage(error, config.baseUrl);
|
||||
health.kind = error instanceof OllamaError ? error.kind : "unknown";
|
||||
if (error instanceof OllamaError && (error.kind === "auth" || error.kind === "http")) {
|
||||
health.keyAccepted = false;
|
||||
}
|
||||
health.latencyMs = null;
|
||||
}
|
||||
|
||||
return Response.json(health satisfies HealthResponse, {
|
||||
status: 200,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
25
app/create/page.tsx
Normal file
25
app/create/page.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
import { TabBar } from "@/components/TabBar";
|
||||
import { UploadScreen } from "@/components/UploadScreen";
|
||||
|
||||
export const metadata = {
|
||||
title: "New deck",
|
||||
};
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="flex min-h-dvh bg-[var(--page)]">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<MobileSidebar />
|
||||
<main className="min-w-0 flex-1">
|
||||
<AppHeader />
|
||||
<div className="mx-auto w-full max-w-3xl px-4 pb-cta lg:px-10 lg:pb-20">
|
||||
<UploadScreen />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/deck/[id]/page.tsx
Normal file
66
app/deck/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { StudyFeed } from "@/components/StudyFeed";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import type { Deck } from "@/lib/schemas";
|
||||
import { getDeck } from "@/lib/storage";
|
||||
|
||||
export default function DeckPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = typeof params.id === "string" ? params.id : "";
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
setDeck(getDeck(id) ?? null);
|
||||
setLoaded(true);
|
||||
}, [id]);
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center">
|
||||
<div className="relative flex items-center gap-3 text-sm font-semibold text-frost-800 dark:text-frost-100">
|
||||
<Spinner className="size-5" /> Loading your deck…
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deck) {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||
<div className="card relative max-w-md space-y-3 p-6 text-center">
|
||||
<p className="text-4xl" aria-hidden>
|
||||
🧊
|
||||
</p>
|
||||
<h1 className="text-xl font-extrabold text-frost-950 dark:text-frost-50">Deck not found</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
This deck isn't in this browser's library. It may have been deleted, or it was
|
||||
created in a different browser profile.
|
||||
</p>
|
||||
<div className="flex justify-center gap-2 pt-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-2xl bg-accent-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-700"
|
||||
>
|
||||
Back to library
|
||||
</Link>
|
||||
<Link href="/create">
|
||||
<Button variant="secondary">Create a deck</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return <StudyFeed key={deck.id} deck={deck} />;
|
||||
}
|
||||
503
app/globals.css
503
app/globals.css
|
|
@ -1,26 +1,499 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Class-based dark mode (toggled on <html> by the theme script). */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
/* Neutral scale (a whisper of cool) — the ChatGPT-ish workhorse. ------- */
|
||||
--color-frost-50: #f8f8f9;
|
||||
--color-frost-100: #f1f1f3;
|
||||
--color-frost-200: #e4e4e7;
|
||||
--color-frost-300: #cfcfd4;
|
||||
--color-frost-400: #a3a3aa;
|
||||
--color-frost-500: #7a7a82;
|
||||
--color-frost-600: #5d5d64;
|
||||
--color-frost-700: #47474d;
|
||||
--color-frost-800: #34343a;
|
||||
--color-frost-900: #26262a;
|
||||
--color-frost-950: #171719;
|
||||
|
||||
/* Accent: one confident blue for buttons and highlights. -------------- */
|
||||
--color-accent-50: #eef7fd;
|
||||
--color-accent-100: #d9edfa;
|
||||
--color-accent-200: #b6dcf4;
|
||||
--color-accent-300: #83c3ea;
|
||||
--color-accent-400: #4aa4dc;
|
||||
--color-accent-500: #2387c4;
|
||||
--color-accent-600: #186ca6;
|
||||
--color-accent-700: #175687;
|
||||
--color-accent-800: #18496f;
|
||||
--color-accent-900: #183e5d;
|
||||
|
||||
/* Aurora hues used only for the frosted backdrop wash. ----------------- */
|
||||
--color-aurora-mint: #9fe3d0;
|
||||
--color-aurora-sky: #9ccdf5;
|
||||
--color-aurora-lilac: #c3b6f0;
|
||||
--color-aurora-peach: #f6cdb4;
|
||||
|
||||
--color-night-50: #f6f9fc;
|
||||
--color-night-700: #1d2733;
|
||||
--color-night-800: #151d27;
|
||||
--color-night-900: #101720;
|
||||
--color-night-950: #0a1017;
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, ui-sans-serif, system-ui,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
|
||||
|
||||
--shadow-frost: 0 14px 34px -22px rgb(13 13 13 / 0.28);
|
||||
--shadow-lift: 0 4px 14px -8px rgb(13 13 13 / 0.18);
|
||||
--shadow-hairline: 0 1px 0 0 rgb(13 13 13 / 0.04);
|
||||
|
||||
--animate-float: float 11s ease-in-out infinite;
|
||||
--animate-pop: pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--animate-wiggle: wiggle 0.4s ease-in-out;
|
||||
--animate-rise: rise 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
--animate-sheet: sheet 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1) }
|
||||
50% { transform: translate3d(0, -18px, 0) scale(1.04) }
|
||||
}
|
||||
@keyframes pop {
|
||||
0% { transform: scale(0.92) }
|
||||
60% { transform: scale(1.02) }
|
||||
100% { transform: scale(1) }
|
||||
}
|
||||
@keyframes wiggle {
|
||||
0%, 100% { transform: translateX(0) }
|
||||
25% { transform: translateX(-5px) }
|
||||
75% { transform: translateX(5px) }
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px) }
|
||||
to { opacity: 1; transform: translateY(0) }
|
||||
}
|
||||
@keyframes sheet {
|
||||
from { opacity: 0; transform: translateY(24px) }
|
||||
to { opacity: 1; transform: translateY(0) }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Theme tokens.
|
||||
Light: ChatGPT-ish neutral (page #ffffff, sidebar #f9f9f9, border #e5e5e5).
|
||||
Dark: page #212121, sidebar #171717, surfaces #2f2f2f, borders rgba(255,255,255,.1).
|
||||
Text tiers are picked to clear WCAG AA (4.5:1) on their own background.
|
||||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
color-scheme: light;
|
||||
--page: #ffffff;
|
||||
--sidebar: #f9f9f9;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-sunken: rgb(13 13 13 / 0.05);
|
||||
--hairline: #e5e5e5;
|
||||
--hairline-soft: rgb(13 13 13 / 0.07);
|
||||
--glass-tint: rgb(255 255 255 / 0.6);
|
||||
--text: #0d0d0d; /* 17.6:1 on #ffffff */
|
||||
--text-muted: #5d5d5d; /* 7.4:1 */
|
||||
--text-subtle: #6b6b6b; /* 5.4:1 */
|
||||
--hover: rgb(13 13 13 / 0.05);
|
||||
--sidebar-top: #fbfbfc;
|
||||
--sidebar-bottom: #f6f6f7;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28);
|
||||
--shadow-card: 0 1px 2px rgb(13 13 13 / 0.06), 0 1px 3px rgb(13 13 13 / 0.04);
|
||||
--shadow-card-hover: 0 10px 28px -14px rgb(13 13 13 / 0.22), 0 1px 2px rgb(13 13 13 / 0.06);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--page: #212121;
|
||||
--sidebar: #171717;
|
||||
--surface: #2f2f2f;
|
||||
--surface-raised: #383838;
|
||||
--surface-sunken: rgb(255 255 255 / 0.08);
|
||||
--hairline: rgb(255 255 255 / 0.14);
|
||||
--hairline-soft: rgb(255 255 255 / 0.08);
|
||||
--glass-tint: rgb(255 255 255 / 0.08);
|
||||
--text: #ececec; /* 12.9:1 on #212121 */
|
||||
--text-muted: #b4b4b4; /* 7.6:1 */
|
||||
--text-subtle: #a0a0a0; /* 6.0:1 */
|
||||
--hover: rgb(255 255 255 / 0.07);
|
||||
--sidebar-top: #1a1a1a;
|
||||
--sidebar-bottom: #151515;
|
||||
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
|
||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Backdrop: a whisper of cool over a neutral base (never a coloured surface). */
|
||||
:root {
|
||||
--bd-sky: rgb(150 200 240 / 0.07);
|
||||
--bd-lilac: rgb(188 178 236 / 0.05);
|
||||
--bd-mint: rgb(150 220 202 / 0.05);
|
||||
--bd-top: #fdfdfd;
|
||||
--bd-mid: #fafafa;
|
||||
--bd-bottom: #f7f7f7;
|
||||
}
|
||||
.dark {
|
||||
--bd-sky: rgb(74 164 220 / 0.05);
|
||||
--bd-lilac: rgb(160 140 235 / 0.04);
|
||||
--bd-mint: rgb(88 201 173 / 0.04);
|
||||
--bd-top: #232323;
|
||||
--bd-mid: #212121;
|
||||
--bd-bottom: #1e1e1e;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* Matches the backdrop so iOS rubber-banding never shows a seam. */
|
||||
background-color: var(--page);
|
||||
color: var(--text);
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
color: var(--color-frost-100);
|
||||
}
|
||||
|
||||
/* The page backdrop now lives in ONE viewport-fixed layer (see .app-backdrop)
|
||||
so it can never stretch, scroll or be clipped by a content column.
|
||||
`.frost-bg` stays as a transparent wrapper for pages that used it. */
|
||||
.frost-bg {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -10;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(135vw 110vh at 4% -30%, var(--bd-sky) 0%, transparent 72%),
|
||||
radial-gradient(125vw 105vh at 104% -18%, var(--bd-lilac) 0%, transparent 70%),
|
||||
radial-gradient(135vw 105vh at 38% 128%, var(--bd-mint) 0%, transparent 72%),
|
||||
linear-gradient(180deg, var(--bd-top) 0%, var(--bd-mid) 50%, var(--bd-bottom) 100%);
|
||||
}
|
||||
|
||||
/* Two slow, very soft blobs for a little life behind the glass. */
|
||||
.app-backdrop::before,
|
||||
.app-backdrop::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(110px);
|
||||
opacity: 0.22;
|
||||
}
|
||||
.app-backdrop::before {
|
||||
width: 52vw;
|
||||
height: 52vw;
|
||||
min-width: 24rem;
|
||||
min-height: 24rem;
|
||||
top: -26vh;
|
||||
left: -18vw;
|
||||
background: radial-gradient(circle, var(--bd-sky), transparent 66%);
|
||||
animation: float 16s ease-in-out infinite;
|
||||
}
|
||||
.app-backdrop::after {
|
||||
width: 46vw;
|
||||
height: 46vw;
|
||||
min-width: 22rem;
|
||||
min-height: 22rem;
|
||||
bottom: -28vh;
|
||||
right: -14vw;
|
||||
background: radial-gradient(circle, var(--bd-mint), transparent 66%);
|
||||
animation: float 20s ease-in-out infinite reverse;
|
||||
}
|
||||
.dark .app-backdrop::before,
|
||||
.dark .app-backdrop::after {
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Glass primitives ------------------------------------------------------ */
|
||||
.glass-panel {
|
||||
@apply backdrop-blur-xl;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.glass-chip {
|
||||
@apply backdrop-blur-md;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.glass-chip:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.glass {
|
||||
/* Tailwind emits both the prefixed and standard properties; hand-written
|
||||
backdrop-filter gets minified down to -webkit- only, which paints nothing. */
|
||||
@apply backdrop-blur-xl backdrop-saturate-150;
|
||||
background: color-mix(in oklab, white 68%, transparent);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.dark .glass {
|
||||
background: color-mix(in oklab, #101720 70%, transparent);
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 1.125rem; /* 18px */
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* Opt-in hover lift for interactive cards (deck cards) */
|
||||
.card-hover {
|
||||
transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.card-hover:hover {
|
||||
background: var(--surface-raised);
|
||||
border-color: color-mix(in oklab, var(--hairline) 55%, var(--text) 18%);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
.card-hover:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Inset grouped list ---------------------------------------------------- */
|
||||
.list-group {
|
||||
border-radius: 1rem;
|
||||
@apply backdrop-blur-lg backdrop-saturate-150;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-group > * + * {
|
||||
border-top: 1px solid var(--hairline-soft);
|
||||
}
|
||||
|
||||
/* iOS segmented control ------------------------------------------------- */
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 0.95rem;
|
||||
@apply backdrop-blur-sm;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
.segmented > button {
|
||||
flex: 1;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.42rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-frost-700);
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.dark .segmented > button {
|
||||
color: var(--color-frost-300);
|
||||
}
|
||||
.segmented > button[aria-pressed="true"],
|
||||
.segmented > button[aria-checked="true"] {
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
box-shadow: 0 1px 2px rgb(13 13 13 / 0.12);
|
||||
}
|
||||
.dark .segmented > button[aria-pressed="true"],
|
||||
.dark .segmented > button[aria-checked="true"] {
|
||||
background: #383838;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Sheet grabber --------------------------------------------------------- */
|
||||
.sheet-grabber {
|
||||
width: 36px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--hairline-soft);
|
||||
margin: 0 auto 0.75rem;
|
||||
}
|
||||
|
||||
.tabbar {
|
||||
@apply backdrop-blur-xl;
|
||||
background: color-mix(in oklab, var(--page) 88%, transparent);
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
/* Soft tinted chip for activity types (a hint of colour, not a rainbow) -- */
|
||||
.tint-sky { background: linear-gradient(140deg, rgb(156 205 245 / 0.34), rgb(156 205 245 / 0.14)); color: #1c4e72 }
|
||||
.tint-mint { background: linear-gradient(140deg, rgb(159 227 208 / 0.4), rgb(159 227 208 / 0.16)); color: #1d5c4c }
|
||||
.tint-lilac { background: linear-gradient(140deg, rgb(195 182 240 / 0.4), rgb(195 182 240 / 0.16)); color: #47397f }
|
||||
.tint-peach { background: linear-gradient(140deg, rgb(246 205 180 / 0.45), rgb(246 205 180 / 0.18)); color: #7d4522 }
|
||||
.tint-rose { background: linear-gradient(140deg, rgb(246 178 190 / 0.4), rgb(246 178 190 / 0.16)); color: #7c2b3d }
|
||||
.dark .tint-sky { background: linear-gradient(140deg, rgb(74 164 220 / 0.26), rgb(74 164 220 / 0.1)); color: #bfe2fb }
|
||||
.dark .tint-mint { background: linear-gradient(140deg, rgb(88 201 173 / 0.26), rgb(88 201 173 / 0.1)); color: #bdf0e2 }
|
||||
.dark .tint-lilac { background: linear-gradient(140deg, rgb(160 140 235 / 0.26), rgb(160 140 235 / 0.1)); color: #ddd5fb }
|
||||
.dark .tint-peach { background: linear-gradient(140deg, rgb(230 160 118 / 0.26), rgb(230 160 118 / 0.1)); color: #fadfc9 }
|
||||
.dark .tint-rose { background: linear-gradient(140deg, rgb(230 130 150 / 0.26), rgb(230 130 150 / 0.1)); color: #fbd5dd }
|
||||
|
||||
/* Sidebar surface: a touch darker than the page, with a real edge. */
|
||||
.sidebar-surface {
|
||||
background: linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));
|
||||
border-right: 1px solid var(--hairline);
|
||||
box-shadow: var(--sidebar-edge);
|
||||
}
|
||||
|
||||
/* Sidebar nav rows */
|
||||
.nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
border-radius: 0.625rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.nav-row:hover {
|
||||
background: var(--hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-row[aria-current="page"],
|
||||
.nav-row[aria-pressed="true"] {
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Shared CTA language: near-black pill in light mode, white pill in dark. */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #0d0d0d;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgb(13 13 13 / 0.18);
|
||||
transition: background-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
.btn-primary:hover { background: #2f2f2f; }
|
||||
.btn-primary:active { transform: scale(0.985); }
|
||||
.dark .btn-primary {
|
||||
background: #ffffff;
|
||||
color: #0d0d0d;
|
||||
box-shadow: none;
|
||||
}
|
||||
.dark .btn-primary:hover { background: #e5e5e5; }
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--hairline);
|
||||
font-weight: 500;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--hover); border-color: color-mix(in oklab, var(--hairline) 70%, var(--text) 12%); }
|
||||
.btn-secondary:active { transform: scale(0.985); }
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
transition: background-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--hover); color: var(--text); }
|
||||
|
||||
/* Difficulty: neutral pill, colour lives in the text only. */
|
||||
.diff-easy { color: #16714f; }
|
||||
.diff-medium { color: #8a5e00; }
|
||||
.diff-hard { color: #b4342f; }
|
||||
.dark .diff-easy { color: #6fd3a8; }
|
||||
.dark .diff-medium { color: #e3b341; }
|
||||
.dark .diff-hard { color: #f08a84; }
|
||||
|
||||
/* Loading skeleton */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--surface-sunken);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.45), transparent);
|
||||
animation: shimmer 1.6s infinite;
|
||||
}
|
||||
.dark .skeleton::after {
|
||||
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.06), transparent);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Focus rings ------------------------------------------------------------ */
|
||||
:where(button, a, input, textarea, [tabindex]):focus-visible {
|
||||
outline: 2.5px solid var(--color-accent-500);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* Scrollbars ------------------------------------------------------------- */
|
||||
.thin-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklab, var(--color-frost-400) 55%, transparent) transparent;
|
||||
}
|
||||
.thin-scroll::-webkit-scrollbar { width: 8px; height: 8px }
|
||||
.thin-scroll::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-frost-400) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar { display: none }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none }
|
||||
|
||||
/* Mobile safe areas ------------------------------------------------------ */
|
||||
.pt-safe { padding-top: max(env(safe-area-inset-top), 0.75rem) }
|
||||
.pb-safe { padding-bottom: max(env(safe-area-inset-bottom), 0.75rem) }
|
||||
.pb-tabbar { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.25rem) }
|
||||
.pb-cta { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 9rem) }
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
.standalone-hide { display: none }
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion --------------------------------------------------------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
app/icon.svg
Normal file
14
app/icon.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="frost" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="100%" stop-color="#0369a1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#frost)" />
|
||||
<g stroke="#ffffff" stroke-width="3.4" stroke-linecap="round" fill="none">
|
||||
<path d="M32 12v40M14.7 22l34.6 20M49.3 22 14.7 42" />
|
||||
<path d="M32 20.5 26.8 15.3M32 20.5l5.2-5.2M32 43.5l-5.2 5.2M32 43.5l5.2 5.2" />
|
||||
<path d="M20.7 27.5l-6.6-1.7M20.7 36.5l-6.6 1.7M43.3 27.5l6.6-1.7M43.3 36.5l6.6 1.7" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 671 B |
|
|
@ -1,33 +1,66 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "Freezmo — turn your notes into a study feed",
|
||||
template: "%s · Freezmo",
|
||||
},
|
||||
description:
|
||||
"Freezmo turns photos and documents of your notes into a swipeable feed of short interactive study activities, powered by your own Ollama models.",
|
||||
applicationName: "Freezmo",
|
||||
manifest: "/manifest.webmanifest",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: "Freezmo",
|
||||
statusBarStyle: "default",
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/icons/favicon-32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/icons/apple-touch-icon.png", sizes: "180x180" },
|
||||
{ url: "/icons/apple-touch-icon-167.png", sizes: "167x167" },
|
||||
{ url: "/icons/apple-touch-icon-152.png", sizes: "152x152" },
|
||||
],
|
||||
},
|
||||
other: {
|
||||
// iOS standalone niceties
|
||||
"apple-mobile-web-app-capable": "yes",
|
||||
"mobile-web-app-capable": "yes",
|
||||
"format-detection": "telephone=no",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f8fafc" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0f13" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Applies the saved theme before paint to avoid a flash. */
|
||||
const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="min-h-full antialiased">
|
||||
<div className="app-backdrop" aria-hidden />
|
||||
{children}
|
||||
<ServiceWorkerRegistrar />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
30
app/manifest.ts
Normal file
30
app/manifest.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* Web app manifest — what makes Freezmo installable on iOS/Android
|
||||
* (iOS reads the apple-* meta tags in app/layout.tsx as well).
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Freezmo — notes to study feed",
|
||||
short_name: "Freezmo",
|
||||
description:
|
||||
"Turn photos and documents of your notes into a swipeable feed of short interactive study activities, powered by your own Ollama models.",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
background_color: "#f8fafc",
|
||||
theme_color: "#f8fafc",
|
||||
categories: ["education", "productivity"],
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
shortcuts: [
|
||||
{ name: "New deck", short_name: "New", url: "/create" },
|
||||
{ name: "Library", short_name: "Library", url: "/" },
|
||||
],
|
||||
};
|
||||
}
|
||||
19
app/offline/page.tsx
Normal file
19
app/offline/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { HomeIcon } from "@/components/icons";
|
||||
|
||||
export const metadata = { title: "Offline" };
|
||||
|
||||
/** Shown by the service worker when a navigation can't reach the network. */
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<main className="frost-bg grid min-h-dvh place-items-center px-4">
|
||||
<div className="card relative max-w-sm space-y-3 p-6 text-center">
|
||||
<HomeIcon className="mx-auto size-8 text-frost-400" />
|
||||
<h1 className="text-xl font-semibold text-frost-950 dark:text-frost-50">You're offline</h1>
|
||||
<p className="text-sm text-frost-600 dark:text-frost-300">
|
||||
Freezmo needs a connection for generating decks and grading answers. Your saved decks are
|
||||
still in this browser — reconnect and reload.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
114
app/page.tsx
114
app/page.tsx
|
|
@ -1,103 +1,25 @@
|
|||
import Image from "next/image";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { InstallHint } from "@/components/InstallHint";
|
||||
import { LibraryScreen } from "@/components/LibraryScreen";
|
||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
import { TabBar } from "@/components/TabBar";
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<div className="flex min-h-dvh bg-[var(--page)]">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<MobileSidebar />
|
||||
<main className="min-w-0 flex-1">
|
||||
<AppHeader />
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-tabbar lg:px-10 lg:pb-16">
|
||||
<div className="mb-6">
|
||||
<InstallHint />
|
||||
</div>
|
||||
<LibraryScreen />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue