diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45159ed --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Public origin used for canonical URLs, Open Graph/Twitter tags, robots.txt +# and sitemap.xml. No trailing slash. +VITE_APP_URL=http://localhost:3000 + +# Leave these blank for local mode. Deployment URLs are public, never API keys. +VITE_CONVEX_URL= +VITE_CONVEX_SITE_URL= + +# Local production host listens on loopback by default. +# PORT=3000 +# HOST=127.0.0.1 + +# --- Scout ----------------------------------------------------------------- +# Scout keeps one SQLite file. Defaults to .data/syllo.db in the repo. +# SCOUT_DB_PATH=C:\path\to\syllo.db + +# Free-only AI. Use an unbilled Google AI Studio project. +# Browser Settings can supply a key instead; it is not synced. +SCOUT_PROVIDER=gemini +SCOUT_MODEL=gemini-2.5-flash + +GEMINI_API_KEY= + +# Optional YouTube Data API key for broader video search and metadata. +# Without it, creator RSS and Gemini grounded search supply candidates. +YOUTUBE_API_KEY= +# Articles use keyless Jina Reader and direct page fetches. +# Paid Exa/Supadata fallbacks and authenticated Jina requests are disabled. + +# Delay between YouTube transcript attempts, in ms. Defaults to 150000 +# (2.5 minutes); values below 30000 are raised to the floor. +# SCOUT_TRANSCRIPT_INTERVAL_MS=150000 diff --git a/.gitignore b/.gitignore index f7b27d4..423b457 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ node_modules -.next +dist +.tanstack .env .env.local -.env.example +.env.*.local coverage -dist +.data *.log *.tsbuildinfo .agents\skills -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/CONTEXT.md b/CONTEXT.md index df1dead..f4f5ba9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,21 +1,41 @@ # Roadmap Studio -Roadmap Studio turns source material into workspaces where a learner can plan, study, and track progress. +Roadmap Studio turns source material into roadmaps where a learner can plan, study, and track progress. ## Language -**Workspace**: -The learner's editable collection of sections, tasks, resources, progress, and settings for one goal. -_Avoid_: Course, project +**Roadmap**: +The learner's editable collection of modules, tasks, resources, notes, review cards, and settings for one goal. +_Avoid_: Workspace, course, project + +**Module**: +One skill inside a Roadmap, holding the tasks, notes, and resources that teach it. +_Avoid_: Section, chapter, lesson + +**Focused Roadmap**: +A Roadmap the learner has chosen to work on now. At most three Roadmaps are focused at once; only focused Roadmaps feed Today. +_Avoid_: Active roadmap, pinned roadmap + +**Next Step**: +The first unfinished task in a Roadmap, in module order. +_Avoid_: Current task, up next + +**Today**: +The queue of actions waiting for the learner: resuming a video, review cards due, the Next Step of each Focused Roadmap, Picks to approve, and the next book chapter. +_Avoid_: Dashboard, home feed + +**Pick**: +A resource Scout proposes for a goal, waiting for the learner to keep or skip it. +_Avoid_: Suggestion, result **Learning Session**: -A period in which a learner works in one Workspace and produces progress, review, timing, or achievement changes. +A period in which a learner works in one Roadmap and produces progress, review, timing, or achievement changes. _Avoid_: Visit, browser session **Learning Record**: -The durable history of learning activity across Workspaces, including streaks, achievements, and daily task counts. +The durable history of learning activity across Roadmaps, including streaks, achievements, and daily task counts. _Avoid_: Analytics data, usage data **Roadmap Intake**: -The conversion of source material and learner choices into a validated Workspace draft. +The conversion of source material and learner choices into a validated Roadmap draft, either from a goal through Scout or from the learner's own notes. _Avoid_: Parsing, generation diff --git a/README.md b/README.md index 4418061..c0e7e3f 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,58 @@ # Syllo -ZNS RoadMap Studio turns raw guides, playbooks, curricula, and notes into interactive learning workspaces. Paste content or upload a markdown file, let the AI structure it, and work through modules, tasks, resources, notes, and progress tracking in one place. +Syllo is a personal learning and reading app. Resume a lesson, read an EPUB, keep notes tied to a source, and review concepts you choose to remember. -## Features +The Spacious design provides Home, Library, Learn, Reviews, Scout, and Settings. Home shows your actual topics and reading position. Library offers book spines or a searchable list. The EPUB reader uses the book's real contents and saves passages, highlights, and your place. Lessons embed videos with saved timestamps and an optional Pomodoro. -- AI-powered parsing for roadmaps, playbooks, tutorials, strategies, and other structured content -- Interactive workspace with modules, milestones, notes, resources, videos, and progress tracking -- Local-first persistence — all data stays in your browser -- Markdown, JSON, and PDF export -- User-configurable AI provider, model, and API key +Scout finds free resources, reads the available source text, and asks you to keep or skip its picks. A generated roadmap stays a draft until you edit and approve it. Suggested review cards only enter the FSRS schedule when kept. Manual resource entry works without AI. -## Stack +## Run locally -- Next.js App Router -- React 19 -- TypeScript -- Tailwind CSS v4 +Use Node 22.12 or newer and npm: -## Getting Started +```powershell +npm ci +npm run dev +``` -1. Install dependencies: +Open `http://localhost:3000`. Convex is optional. With `VITE_CONVEX_URL` blank, progress uses browser storage and EPUB files use IndexedDB. No account is needed. -```bash -npm install -``` +Read [setup instructions](docs/syllo-setup.md) for optional private Convex sync, free Gemini configuration, the local transcript worker, Android use, and backups. No Convex project is configured in this checkout. -2. Create `.env.local` with the variables you need: +## Stack and data -```env -# Access gate (password lock) - required -GATE_PASSWORD=change-me-to-a-strong-password +TanStack Start and Router, React 19, TypeScript, Tailwind v4, epub.js, ts-fsrs, and an optional Convex backend. Scout runs in the Node server with a local SQLite database. It needs a Gemini key from an unbilled Google AI Studio project for generation; paid provider fallbacks are disabled. -# Optional: your own AI provider keys (used server-side for shared studio AI) -AI_PROVIDER=gemini -AI_API_KEY= -GEMINI_API_KEY= -OPENAI_API_KEY= -GROQ_API_KEY= -OPENROUTER_API_KEY= -NEXT_PUBLIC_APP_URL=http://localhost:3000 -``` +Existing workspace routes, editing tools, and the `zns:v2:workspace-state` storage key remain supported. The new bridge preserves an original local backup before syncing roadmaps and review cards. Settings exports metadata, including the legacy workspace backup. Original EPUBs and API keys are excluded. -3. Start the app: +Convex sync is prepared for one owner, with password login restricted by `SYLLO_OWNER_EMAIL` and authenticated file downloads. Without a configured deployment, all local features remain available. Live cloud integration still needs a project and account setup. -```bash -npm run dev -``` +## Development -## Scripts +```powershell +npm run typecheck +npm run typecheck:convex +npm test +npm run lint +npm run build +npm start +``` -- `npm run dev` - start the local dev server -- `npm run build` - run a production build -- `npm run start` - start the production server -- `npm run lint` - run ESLint -- `npm run typecheck` - run TypeScript without emitting files +The production server serves `dist/client` and the TanStack SSR handler on loopback port 3000. `npm start` does not read an env file automatically; pass environment variables or use `node --env-file=.env.local server.mjs`. Deployment URLs are build-time settings. -## Project Notes +`mcp/server.mjs` supplies a local stdio MCP server for coding agents to add and search resource candidates. Connection snippets are available in Settings. -- All data is stored locally in the browser (workspaces, notes, progress, credits). -- Custom AI keys are stored in browser storage and sent only to the selected provider when used. -- Optional server-side AI keys can be set in `.env.local` for shared studio AI. +## Project layout -## Open Source +```text +src/routes/ Routes and server API handlers +src/views/studio/ Learning, reading, review, Scout, and settings pages +src/components/learning/ EPUB covers, video player, cards, and Pomodoro +src/components/sync/ Optional Convex provider and local workspace bridge +src/lib/personal/ Validated records, browser files, and sync references +src/lib/scout/ Discovery, extraction, planning, SQLite, and jobs +convex/ Private account, document sync, and file functions +mcp/ Local coding-agent resource tools +``` -This repository is still in active development. Expect UI and schema changes while the workspace model evolves. +This app is personal-first. Forks can use their own deployment, books, preferences, and keys. Public publishing and multi-user collaboration are outside v1. Earlier gallery/share routes remain in the codebase; personal sync does not publish learning records through them. diff --git a/app/(marketing)/gallery/loading.tsx b/app/(marketing)/gallery/loading.tsx deleted file mode 100644 index aee7d85..0000000 --- a/app/(marketing)/gallery/loading.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; - -export default function GalleryLoading() { - const skeletonCards = Array.from({ length: 6 }, (_, index) => index); - - return ( -
-
-
- - - -
- - - -
- {skeletonCards.map((index) => ( - -
-
-
-
-
-
-
-
-
- - ))} -
-
-
- ); -} diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx deleted file mode 100644 index d5c7076..0000000 --- a/app/(marketing)/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { ReactNode } from "react"; -import Header from "@/components/layout/Header"; -import Footer from "@/components/layout/Footer"; - -export default function MarketingLayout({ children }: { children: ReactNode }) { - return ( -
-
-
{children}
-
-
- ); -} diff --git a/app/(marketing)/pricing/layout.tsx b/app/(marketing)/pricing/layout.tsx deleted file mode 100644 index c97c577..0000000 --- a/app/(marketing)/pricing/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Pricing", - description: "Compare Free, Pro, and Agency plans for ZNS RoadMap Studio.", - alternates: { - canonical: "/pricing", - }, -}; - -export default function PricingLayout({ children }: { children: React.ReactNode }) { - return children; -} diff --git a/app/(studio)/analytics/page.tsx b/app/(studio)/analytics/page.tsx deleted file mode 100644 index f87b018..0000000 --- a/app/(studio)/analytics/page.tsx +++ /dev/null @@ -1,509 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { Flame, TrendingUp, CheckCircle2, BookOpen, Trophy, Calendar, ArrowRight, Star } from "lucide-react"; -import { getStorage } from "@/lib/storage"; -import { getRoadmapStats } from "@/lib/workspace-stats"; -import type { Roadmap, ModuleSection } from "@/types"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -interface DayCell { - date: string; // YYYY-MM-DD - count: number; // tasks completed on that day (approximated from updatedAt) - level: 0 | 1 | 2 | 3 | 4; // intensity bucket -} - -interface Milestone { - id: string; - roadmapTitle: string; - roadmapId: string; - label: string; - completedAt: string; // ISO date string - type: "roadmap-complete" | "module-complete" | "halfway"; -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function isoToYMD(iso: string): string { - return iso.slice(0, 10); -} - -function addDays(dateStr: string, n: number): string { - const d = new Date(dateStr); - d.setDate(d.getDate() + n); - return d.toISOString().slice(0, 10); -} - -function today(): string { - return new Date().toISOString().slice(0, 10); -} - -function daysBetween(a: string, b: string): number { - const msA = new Date(a).getTime(); - const msB = new Date(b).getTime(); - return Math.round(Math.abs(msB - msA) / 86400000); -} - -function buildConsistencyGrid(roadmaps: Roadmap[]): DayCell[] { - // Build a 365-day window (52 weeks + padding) - const todayStr = today(); - const startStr = addDays(todayStr, -363); // ~52 weeks back - - // Count completions per day using roadmap updatedAt as a proxy - // In a real system you'd store per-task completion timestamps - const counts: Record = {}; - - for (const roadmap of roadmaps) { - const dateStr = isoToYMD(roadmap.updatedAt || roadmap.createdAt || todayStr); - if (dateStr >= startStr && dateStr <= todayStr) { - const stats = getRoadmapStats(roadmap); - // Weight by how many tasks are completed - counts[dateStr] = (counts[dateStr] || 0) + Math.max(1, Math.floor(stats.completedTasks / 2)); - } - } - - const learning = getStorage().getLearningRecord(); - for (const [date, count] of Object.entries(learning.tasksByDate)) { - if (date >= startStr && date <= todayStr) counts[date] = (counts[date] || 0) + count; - } - - // Build the full 364-day array - const cells: DayCell[] = []; - const maxCount = Math.max(1, ...Object.values(counts)); - - for (let i = 0; i < 364; i++) { - const dateStr = addDays(startStr, i); - const count = counts[dateStr] || 0; - let level: DayCell["level"] = 0; - if (count > 0) { - const ratio = count / maxCount; - if (ratio < 0.25) level = 1; - else if (ratio < 0.5) level = 2; - else if (ratio < 0.75) level = 3; - else level = 4; - } - cells.push({ date: dateStr, count, level }); - } - - return cells; -} - -function extractMilestones(roadmaps: Roadmap[]): Milestone[] { - const milestones: Milestone[] = []; - - for (const roadmap of roadmaps) { - const stats = getRoadmapStats(roadmap); - const title = roadmap.title || "Untitled Workspace"; - const baseDate = roadmap.updatedAt || roadmap.createdAt || today(); - - // Completed roadmap - if (stats.progressState === "completed") { - milestones.push({ - id: `${roadmap.id}-complete`, - roadmapTitle: title, - roadmapId: roadmap.id, - label: `Completed "${title}"`, - completedAt: baseDate, - type: "roadmap-complete", - }); - } - - // Halfway milestone - if (stats.percent >= 50 && stats.progressState !== "not-started") { - milestones.push({ - id: `${roadmap.id}-halfway`, - roadmapTitle: title, - roadmapId: roadmap.id, - label: `Reached 50% in "${title}"`, - completedAt: baseDate, - type: "halfway", - }); - } - - // Completed modules - const moduleSections = roadmap.sections.filter((s) => s.type === "module") as ModuleSection[]; - for (const mod of moduleSections) { - if (mod.data.completed) { - milestones.push({ - id: `${roadmap.id}-${mod.id}-module`, - roadmapTitle: title, - roadmapId: roadmap.id, - label: `Finished module: ${mod.title || "Untitled Module"}`, - completedAt: baseDate, - type: "module-complete", - }); - } - } - } - - // Sort newest first - return milestones - .sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()) - .slice(0, 20); -} - -// ─── Sub-components ─────────────────────────────────────────────────────────── - -function ConsistencyMatrix({ cells }: { cells: DayCell[] }) { - const LEVEL_COLORS = [ - "bg-[var(--color-surface-muted)]", // 0 – empty - "bg-indigo-900/60", // 1 – light - "bg-indigo-700/70", // 2 – medium - "bg-indigo-500", // 3 – strong - "bg-indigo-400", // 4 – max - ]; - - // Build columns (weeks) - const weeks: DayCell[][] = []; - for (let i = 0; i < cells.length; i += 7) { - weeks.push(cells.slice(i, i + 7)); - } - - const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - const DAYS = ["S", "M", "T", "W", "T", "F", "S"]; - - // Month labels - const monthLabels: { label: string; col: number }[] = []; - let lastMonth = -1; - weeks.forEach((week, colIdx) => { - const firstCell = week[0]; - if (firstCell) { - const m = new Date(firstCell.date).getMonth(); - if (m !== lastMonth) { - monthLabels.push({ label: MONTHS[m], col: colIdx }); - lastMonth = m; - } - } - }); - - const todayStr = today(); - - return ( -
-
- {/* Month labels */} -
- {weeks.map((_, colIdx) => { - const label = monthLabels.find((m) => m.col === colIdx); - return ( -
- {label ? label.label : ""} -
- ); - })} -
- -
- {/* Day-of-week labels */} -
- {DAYS.map((d, i) => ( -
- {d} -
- ))} -
- - {/* Grid */} - {weeks.map((week, weekIdx) => ( -
- {week.map((cell, dayIdx) => ( -
- ))} -
- ))} -
- - {/* Legend */} -
- Less - {LEVEL_COLORS.map((cls, i) => ( -
- ))} - More -
-
-
- ); -} - -function MilestoneTypeIcon({ type }: { type: Milestone["type"] }) { - if (type === "roadmap-complete") return ; - if (type === "halfway") return ; - return ; -} - -function StatCard({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: string | number; sub?: string }) { - return ( -
-
- {icon} -
-
-

{label}

-

{value}

- {sub &&

{sub}

} -
-
- ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - -export default function AnalyticsPage() { - const [roadmaps, setRoadmaps] = useState([]); - const [mounted, setMounted] = useState(false); - const [streak, setStreak] = useState(0); - const [tasksToday, setTasksToday] = useState(0); - - useEffect(() => { - const storage = getStorage(); - setMounted(true); - setRoadmaps(storage.getRoadmaps()); - - const learning = storage.getLearningRecord(); - const todayKey = new Date().toLocaleDateString("en-CA"); - setStreak(learning.currentStreak); - setTasksToday(learning.tasksByDate[todayKey] ?? 0); - }, []); - - const cells = useMemo(() => (mounted ? buildConsistencyGrid(roadmaps) : []), [mounted, roadmaps]); - const milestones = useMemo(() => (mounted ? extractMilestones(roadmaps) : []), [mounted, roadmaps]); - - const globalStats = useMemo(() => { - let totalTasks = 0; - let completedTasks = 0; - let completedRoadmaps = 0; - let inProgressRoadmaps = 0; - - for (const r of roadmaps) { - const stats = getRoadmapStats(r); - totalTasks += stats.totalTasks; - completedTasks += stats.completedTasks; - if (stats.progressState === "completed") completedRoadmaps++; - if (stats.progressState === "in-progress") inProgressRoadmaps++; - } - - const overallPct = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; - return { totalTasks, completedTasks, completedRoadmaps, inProgressRoadmaps, overallPct }; - }, [roadmaps]); - - // Calculate active days from the cells array - const activeDays = useMemo(() => cells.filter((c) => c.count > 0).length, [cells]); - - // Top roadmaps by progress - const topRoadmaps = useMemo(() => { - return [...roadmaps] - .map((r) => ({ roadmap: r, stats: getRoadmapStats(r) })) - .filter((x) => x.stats.totalTasks > 0) - .sort((a, b) => b.stats.percent - a.stats.percent) - .slice(0, 5); - }, [roadmaps]); - - return ( -
- {/* Header */} -
-

Analytics

-

- Learning Progress -

-

- Track your consistency, celebrate milestones, and see the full picture of your learning journey. -

-
- - {mounted ? ( -
- {/* ── Stat Cards ── */} -
- } - label="Day Streak" - value={streak} - sub={tasksToday > 0 ? `${tasksToday} tasks today` : "No tasks yet today"} - /> - } - label="Tasks Done" - value={globalStats.completedTasks} - sub={`of ${globalStats.totalTasks} total`} - /> - } - label="Active Days" - value={activeDays} - sub="in the last year" - /> - } - label="Completed" - value={globalStats.completedRoadmaps} - sub={`${globalStats.inProgressRoadmaps} in progress`} - /> -
- - {/* ── Consistency Matrix ── */} -
-
-

- Consistency Matrix -

- {activeDays} active day{activeDays !== 1 ? "s" : ""} in the past year -
-
- {cells.length === 0 ? ( -
No activity recorded yet. Start completing tasks to fill this in!
- ) : ( - - )} -
-
- - {/* ── Two Columns: Top Roadmaps + Milestones ── */} -
- - {/* Top Roadmaps */} -
-

- Roadmap Progress -

-
- {topRoadmaps.length === 0 ? ( -
-

No workspaces yet.

- - Create your first roadmap → - -
- ) : ( - topRoadmaps.map(({ roadmap, stats }) => ( - -
-
-

{roadmap.title || "Untitled Workspace"}

- {stats.percent}% -
-
-
-
-

- {stats.completedTasks} / {stats.totalTasks} tasks · {stats.moduleCount} module{stats.moduleCount !== 1 ? "s" : ""} -

-
- - - )) - )} -
-
- - {/* Milestones Timeline */} -
-

- Milestones -

- {milestones.length === 0 ? ( -
- -

Complete modules and roadmaps to unlock milestones.

-
- ) : ( -
- {milestones.map((ms, idx) => ( -
- {/* Dot */} -
- -
- -
-

{ms.label}

-
- - {new Date(ms.completedAt).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - - · - - {ms.roadmapTitle} - -
-
-
- ))} -
- )} -
-
- - {/* ── Overall Summary Bar ── */} -
-
-
-

Overall Completion

-

{globalStats.overallPct}%

-
-
- - - {globalStats.completedTasks} completed - - - - {globalStats.totalTasks - globalStats.completedTasks} remaining - -
-
-
-
-
-
-
- ) : ( - /* Skeleton */ -
-
- {[...Array(4)].map((_, i) => ( -
- ))} -
-
-
-
-
-
-
- )} -
- ); -} diff --git a/app/(studio)/create/layout.tsx b/app/(studio)/create/layout.tsx deleted file mode 100644 index 70817c6..0000000 --- a/app/(studio)/create/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Create Workspace", - description: "Create a new interactive workspace from your guide, roadmap, or curriculum.", - robots: { - index: false, - follow: false, - }, -}; - -export default function CreateLayout({ children }: { children: React.ReactNode }) { - return children; -} diff --git a/app/(studio)/create/page.tsx b/app/(studio)/create/page.tsx deleted file mode 100644 index 3141389..0000000 --- a/app/(studio)/create/page.tsx +++ /dev/null @@ -1,337 +0,0 @@ -"use client"; - -import { Suspense, useCallback, useRef, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { ChevronDown, ChevronUp, FileUp, KeyRound, Loader2, Wand2, ArrowRight, ArrowLeft } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { getStorage } from "@/lib/storage"; -import { getActiveApiKey, getActiveModel, getUserConfig } from "@/lib/userConfig"; -import type { Roadmap } from "@/types"; -import { cn } from "@/lib/utils"; - -const LOADING_MESSAGES = [ - "Reading your material...", - "Extracting key concepts...", - "Structuring modules...", - "Finalizing steps..." -]; - -export default function CreatePage() { - return ( - }> - - - ); -} - -function CreatePageContent() { - const router = useRouter(); - const fileInputRef = useRef(null); - - const [creationStep, setCreationStep] = useState<"input" | "generating" | "review">("input"); - - // Form state - const [title, setTitle] = useState(""); - const [content, setContent] = useState(""); - const [mode, setMode] = useState<"general" | "intern">("general"); - - // UI state - const [isDragging, setIsDragging] = useState(false); - const [loadingStep, setLoadingStep] = useState(0); - const [error, setError] = useState(""); - const [upgradeMessage, setUpgradeMessage] = useState(""); - - // Draft State - const [draftRoadmap, setDraftRoadmap] = useState(null); - - const hasContent = content.trim().length > 0; - - const processFile = useCallback( - (file: File) => { - if (!file.name.match(/\.(md|txt|markdown)$/i)) { - setError("Only .md, .txt, and .markdown files are supported."); - return; - } - setError(""); - const reader = new FileReader(); - reader.onload = (event) => { - const nextContent = (event.target?.result as string) || ""; - setContent(nextContent); - if (!title.trim()) setTitle(file.name.replace(/\.\w+$/, "")); - }; - reader.readAsText(file); - }, - [title], - ); - - const handleGenerate = async () => { - if (!hasContent) { - setError("Paste or upload content before generating."); - return; - } - - setError(""); - setCreationStep("generating"); - setLoadingStep(0); - - // Simulating the stages visually - const interval = setInterval(() => { - setLoadingStep(prev => Math.min(prev + 1, 3)); - }, 1200); - - try { - const userConfig = getUserConfig(); - const activeApiKey = getActiveApiKey(userConfig); - const useCustomKey = Boolean(userConfig.useCustomKey && activeApiKey); - - const response = await fetch("/api/parse-roadmap", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: content.trim(), - mode, - title: title.trim() || undefined, - userApiKey: useCustomKey ? activeApiKey : undefined, - userModel: useCustomKey ? getActiveModel(userConfig) : undefined, - userProvider: useCustomKey ? userConfig.provider : undefined, - }), - }); - - clearInterval(interval); - - if (!response.ok) { - const payload = await response.json().catch(() => ({ error: "Failed to generate workspace" })); - if (payload.error === "limit_reached") { - setUpgradeMessage(payload.message ?? "Plan limit reached."); - setCreationStep("input"); - return; - } - throw new Error(payload.message ?? payload.error ?? "Something went wrong"); - } - - const payload = await response.json(); - if (!payload.success || !payload.roadmap) { - throw new Error(payload.error ?? "Incomplete response."); - } - - let loadedTitle = payload.roadmap.title; - if (!loadedTitle || loadedTitle === "Untitled Course") { - loadedTitle = title.trim() || "Untitled Workspace"; - } - - const roadmap: Roadmap = { - ...payload.roadmap, - title: loadedTitle, - id: crypto.randomUUID(), - rawContent: content.trim(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - setDraftRoadmap(roadmap); - setCreationStep("review"); - - } catch (nextError) { - clearInterval(interval); - setError(nextError instanceof Error ? nextError.message : "An unexpected error occurred"); - setCreationStep("input"); - } - }; - - const handleCreateFinal = () => { - if (!draftRoadmap) return; - getStorage().saveRoadmap(draftRoadmap); - router.push(`/workspace/${draftRoadmap.id}`); - }; - - return ( - <> -
- {creationStep !== "review" && ( -
-

Content Import

-

- Turn scattered notes into a roadmap. -

-

- Drop in your guides, PDFs (as text for now), or markdown. We'll extract the steps and modules. -

-
- )} - - {creationStep === "input" && ( -
-
-
- - setTitle(e.target.value)} - placeholder="e.g. Master Next.js 15" - className="bg-[var(--color-surface)]" - /> -
-
-

Parsing Mode

-
- - -
-
-
- -
-