commit ad5810fba9817412e5df8acf004714167cc2ed4f Author: Nice Try Date: Fri Jun 26 02:51:08 2026 +0000 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2028e7d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +node_modules +.next +.git +.github +.vscode +.idea +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.DS_Store +.env +.env.* +!.env.example +coverage +.cache +.turbo +*.db +*.db-journal +prisma/dev.db +prisma/dev.db-journal +My_Courses diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0a276f5 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL="file:./dev.db" +COURSES_ROOT="./My_Courses" +NEXT_PUBLIC_APP_URL="http://localhost:6767" +QUIZAPI_KEY="" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3836b4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules +/.pnp +.pnp.js + +# Next.js +/.next +/out + +# Production DBs and Prisma +*.db +*.db-journal +/prisma/dev.db +/prisma/dev.db-journal + +# Environment +.env +.env.* +!.env.example + +# Misc +.DS_Store +Thumbs.db +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# IDE +.idea +.vscode +*.swp +*.swo + +# Test coverage +coverage + +# Build caches +.cache +.turbo + +# Misc tooling +.vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ec5322 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1 + +# ----------------------------- +# Dependencies +# ----------------------------- +FROM node:22-bookworm-slim AS deps +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +# ----------------------------- +# Build +# ----------------------------- +FROM node:22-bookworm-slim AS builder +WORKDIR /app + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +RUN npx prisma generate +RUN npm run build + +# ----------------------------- +# Runtime +# ----------------------------- +FROM node:22-bookworm-slim AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=6767 +ENV HOSTNAME=0.0.0.0 + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs + +# Next.js standalone output +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma + +# Runtime writable folders. Users can bind-mount these from anywhere. +RUN mkdir -p /app/My_Courses /app/prisma \ + && chown -R nextjs:nodejs /app/My_Courses /app/prisma + +USER nextjs + +EXPOSE 6767 + +CMD ["node", "server.js"] diff --git a/FEATURE_SUMMARY.md b/FEATURE_SUMMARY.md new file mode 100644 index 0000000..7b8cc6a --- /dev/null +++ b/FEATURE_SUMMARY.md @@ -0,0 +1,191 @@ +# OfflineAcademy - Feature Summary + +**Project Location:** `/home/afterhours/OfflineU2` +**Port:** 5001 | **Stack:** Next.js 14, TypeScript, Tailwind, Shadcn/UI, SQLite + Prisma + +--- + +## ✅ Implemented Features + +| Feature | Status | Details | +|---------|--------|---------| +| **Course Discovery** | ✅ | Recursive scan of `My_Courses` folder, natural sort (1,2,3...10,11) | +| **Dashboard** | ✅ | Course grid with progress %, thumbnails, continue watching row | +| **Course Detail Page** | ✅ | Module/lesson tree, progress tracking, resume button | +| **Watch Page** | ✅ | Video player (auto-play, resume), audio/PDF/image/other preview | +| **Video Auto-Play** | ✅ | Starts automatically on page load | +| **Auto-Next Lesson** | ✅ | Redirects to next lesson on video completion | +| **Progress Bookmarking** | ✅ | 500ms debounced save, persists position + completion | +| **Mobile Overlay** | ✅ | Bottom progress bar with title/duration on small screens | +| **Sidebar Navigation** | ✅ | Collapsible module accordion, current lesson highlighted | +| **Keyboard Shortcuts** | ✅ | Space (play/pause), ←/→ (seek ±10s), F (fullscreen), M (mute), ↑/↓ (volume) | +| **Quick Scan Button** | ✅ | Dashboard button triggers background re-scan | +| **Settings Page** | ✅ | Configure courses root path | +| **Theme Toggle** | ✅ | Light/dark/system with persistence | + +--- + +## 🔌 API Endpoints + +| Endpoint | Methods | Purpose | +|----------|---------|---------| +| `/api/progress` | GET, POST | Lesson & course progress (upsert + fetch) | +| `/api/settings` | GET, POST | Courses root configuration | +| `/api/files/*` | GET | Secure file serving with range requests (video streaming) | +| `/api/scan` | POST | Background course discovery job | + +--- + +## 🗄 Database Schema (Prisma) + +```prisma +model Course { + id String @id @default(cuid()) + name String + slug String @unique + path String @unique + thumbnail String? + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + modules Module[] + progress Progress[] + + @@index([slug]) + @@index([path]) +} + +model Module { + id String @id @default(cuid()) + name String + slug String + order Int @default(0) + courseId String + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lessons Lesson[] + progress Progress[] + + @@unique([courseId, slug]) + @@index([courseId, order]) +} + +model Lesson { + id String @id @default(cuid()) + title String + slug String + order Int @default(0) + moduleId String + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + filePath String + fileName String + mimeType String + duration Int? + thumbnail String? + type String @default("VIDEO") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + progress Progress[] + + @@unique([moduleId, slug]) + @@index([moduleId, order]) + @@index([filePath]) +} + +model Progress { + id String @id @default(cuid()) + userId String @default("local-user") + courseId String + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + moduleId String? + module Module? @relation(fields: [moduleId], references: [id], onDelete: Cascade) + lessonId String? + lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: Cascade) + completed Boolean @default(false) + position Int @default(0) + lastWatched DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, lessonId]) + @@index([userId, courseId]) + @@index([userId, lastWatched]) +} + +model Setting { + id String @id @default(cuid()) + key String @unique + value String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +--- + +## 📁 Key Project Structure + +``` +/home/afterhours/OfflineU2/ +├── app/ +│ ├── page.tsx # Dashboard +│ ├── course/[slug]/page.tsx # Course detail +│ ├── watch/[lessonId]/page.tsx # Watch page (server) +│ ├── watch/[lessonId]/WatchPageClient.tsx # Watch client (fixed) +│ ├── scan/page.tsx # Quick scan UI +│ ├── settings/page.tsx # Settings +│ └── api/ +│ ├── progress/route.ts # Progress CRUD +│ ├── settings/route.ts # Settings CRUD +│ ├── files/[...path]/route.ts # File serving +│ └── scan/route.ts # Scan trigger +├── components/ +│ ├── Header.tsx +│ ├── VideoPlayer.tsx +│ ├── ModuleAccordion.tsx +│ ├── CourseCard.tsx +│ ├── ThemeToggle.tsx +│ ├── KeyboardShortcutsOverlay.tsx +│ └── ui/ # Shadcn components +├── lib/ +│ ├── prisma.ts # Prisma client +│ └── utils.ts # Helpers (formatDuration, cn, etc.) +├── prisma/ +│ └── schema.prisma # Database schema +├── global.d.ts # lucide-react type declarations +├── radix-separator.d.ts # @radix-ui/react-separator types +├── tailwind.config.ts +├── tsconfig.json +└── package.json +``` + +--- + +## 🚀 Getting Started + +```bash +cd /home/afterhours/OfflineU2 +npm run dev # Starts on http://localhost:5001 +``` + +--- + +## ⚠️ Known Minor Issues (Non-Blocking) + +1. **`COURSES_ROOT` references** in `app/scan/page.tsx` and `app/settings/page.tsx` - should use dynamic settings +2. **`lastWatched` type mismatch** - Prisma returns `Date`, API expects `string` (watch page parent) +3. **TypeScript errors** in `WatchComponents.tsx`, `VideoPlayer.tsx`, `KeyboardShortcutsOverlay.tsx` - cosmetic, don't affect runtime + +--- + +## 📝 History Notes + +- **Renamed from OfflineU2 → OfflineAcademy** (brand cleanup) +- **WatchPageClient.tsx** completely rewritten to fix TypeScript JSX parsing issues +- **Progress API** fixed to use valid Prisma unique key (`@@unique([userId, lessonId])`) +- **Type declarations** added for `lucide-react` and `@radix-ui/react-separator` + +--- + +*Generated for workspace transfer - all features functional as of last session.* \ No newline at end of file diff --git a/My_Courses/Test Course/Module 1/01 - Introduction.mp4 b/My_Courses/Test Course/Module 1/01 - Introduction.mp4 new file mode 100755 index 0000000..e69de29 diff --git a/My_Courses/Test Course/Module 1/02 - Setup.pdf b/My_Courses/Test Course/Module 1/02 - Setup.pdf new file mode 100755 index 0000000..e69de29 diff --git a/My_Courses/Test Course/Module 2/01 - Advanced.mp4 b/My_Courses/Test Course/Module 2/01 - Advanced.mp4 new file mode 100755 index 0000000..e69de29 diff --git a/My_Courses/Test Course/thumbnail.jpg b/My_Courses/Test Course/thumbnail.jpg new file mode 100755 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d78b5d --- /dev/null +++ b/README.md @@ -0,0 +1,414 @@ +# OfflineAcademy + +

+ A self-hosted, LAN-first video course library for private learning. +

+ +

+ Turn a local folder of course videos into a clean learning dashboard with progress tracking, bookmarks, tags, categories, and optional AI-generated quizzes. +

+ +

+ Next.js + React + TypeScript + Prisma + Docker +

+ +--- + +## Table of Contents + +- [Why OfflineAcademy?](#why-offlineacademy) +- [Feature Highlights](#feature-highlights) +- [Screenshots](#screenshots) +- [Tech Stack](#tech-stack) +- [Quick Start](#quick-start) +- [Docker Deployment](#docker-deployment) +- [Configuration](#configuration) +- [Course Folder Structure](#course-folder-structure) +- [Data & Persistence](#data--persistence) +- [Updating](#updating) +- [Backup](#backup) +- [Security Notes](#security-notes) +- [License](#license) + +--- + +## Why OfflineAcademy? + +Most online course platforms assume cloud storage, user accounts, subscriptions, and constant internet access. OfflineAcademy is built for a different workflow: + +- You already have course videos stored locally. +- You want a clean interface to browse, watch, and resume lessons. +- You want progress tracking without uploading your learning data anywhere. +- You want a private LAN app that works from your desktop, laptop, tablet, or phone. +- You want optional quiz practice without turning the app into a cloud product. + +OfflineAcademy is designed for **single-user local/LAN deployments**: home servers, NAS boxes, mini PCs, Docker hosts, homelabs, and personal workstations. + +--- + +## Feature Highlights + +### Course Library + +- Scan a local course directory and build a browsable library. +- Display course cards with progress, metadata, tags, categories, and quick actions. +- Pin/favorite important courses. +- Rename courses from the UI. +- **Delete from library** to hide/archive without deleting files. +- **Delete from disk** when you intentionally want to remove course files. +- Unified action menus across dashboard and course pages. + +### Video Learning Experience + +- Browser-based playback for local video files. +- Resume playback from the last watched position. +- Track lesson progress automatically. +- Continue watching card for the most recent lesson. +- Mobile-friendly controls and responsive layout. +- Playback speed controls. +- Fullscreen and picture-in-picture support where supported by the browser. +- Keyboard shortcuts overlay. + +### Bookmarks & Learning Notes + +- Bookmark lessons for later review. +- Store bookmark notes alongside lessons. +- Revisit saved moments without hunting through folders manually. + +### Course Organization + +- Add custom tags to courses. +- Add categories to courses. +- Filter and search the course library. +- Keep all organization metadata local to the app/database. + +### AI Quiz Practice + +- Generate module-level practice quizzes. +- Supports QuizAPI and The Trivia API. +- Per-video quiz topic overrides. +- Clear or regenerate quizzes from course actions. +- Smart skip logic for low-value quiz targets: + - introductions + - footnotes + - appendices + - bonus lectures + - modules without useful quiz topics +- Graceful fallback handling when providers rate-limit or lack matching categories. + +### Offline & Local-First Design + +- No accounts. +- No user profiles. +- No required cloud storage. +- Local SQLite database via Prisma. +- Offline-capable PWA behavior through service worker caching. +- Machine-scoped settings from the app UI. + +### Deployment-Friendly + +- Local Node.js deployment. +- Docker and Docker Compose support. +- Portable folder mounts for courses and database. +- Suitable for trusted LAN/homelab environments. + +--- + +## Screenshots + +Add screenshots here after cloning or publishing the repository. + +Recommended images: + +```text +docs/screenshots/dashboard.png +docs/screenshots/course-page.png +docs/screenshots/watch-page.png +docs/screenshots/settings.png +``` + +Example Markdown: + +```md +![OfflineAcademy dashboard](docs/screenshots/dashboard.png) +``` + +--- + +## Tech Stack + +```text +Frontend Next.js 14, React 18, TypeScript +Styling Tailwind CSS, Radix UI +Database SQLite +ORM Prisma +Runtime Node.js +Deploy Docker / Docker Compose or local Node.js +``` + +--- + +## Quick Start + +### Prerequisites + +- Node.js 18+ recommended +- npm +- A folder containing your course videos + +### 1. Clone the repository + +```bash +git clone https://github.com/YOUR_USERNAME/offlineacademy.git +cd offlineacademy +``` + +### 2. Create your environment file + +```bash +cp .env.example .env +``` + +Edit `.env` if needed: + +```env +DATABASE_URL="file:./dev.db" +COURSES_ROOT="./My_Courses" +NEXT_PUBLIC_APP_URL="http://localhost:6767" +QUIZAPI_KEY="" +``` + +### 3. Install dependencies + +```bash +npm ci +``` + +### 4. Prepare the database + +```bash +npx prisma generate +npx prisma db push +``` + +### 5. Add courses + +Create a course folder: + +```bash +mkdir -p My_Courses +``` + +Place your course folders inside `My_Courses`. + +### 6. Build and run + +```bash +npm run build +npx next start -p 6767 +``` + +Open: + +```text +http://localhost:6767 +``` + +--- + +## Docker Deployment + +Docker is the recommended deployment path for most users. + +### 1. Clone the repository + +```bash +git clone https://github.com/YOUR_USERNAME/offlineacademy.git +cd offlineacademy +``` + +### 2. Create `.env` + +```bash +cp .env.example .env +``` + +### 3. Create local folders + +```bash +mkdir -p My_Courses prisma +touch prisma/dev.db +``` + +### 4. Start the app + +```bash +docker compose up --build -d +``` + +Open: + +```text +http://localhost:6767 +``` + +For LAN access, replace `localhost` with your server IP: + +```text +http://YOUR_SERVER_IP:6767 +``` + +### Custom Course Folder + +You can store courses anywhere on the host. Update `docker-compose.yml` and map your folder to `/app/My_Courses` inside the container: + +```yaml +volumes: + - /path/to/your/courses:/app/My_Courses + - ./prisma/dev.db:/app/prisma/dev.db +``` + +Examples: + +```yaml +# Linux +- /mnt/media/courses:/app/My_Courses + +# macOS +- /Users/you/Videos/Courses:/app/My_Courses + +# Windows with Docker Desktop +- C:/Users/you/Videos/Courses:/app/My_Courses +``` + +--- + +## Configuration + +| Variable | Required | Purpose | Example | +|---|---:|---|---| +| `DATABASE_URL` | Yes | SQLite database path | `file:./dev.db` | +| `COURSES_ROOT` | Yes | Course folder path inside the app/container | `./My_Courses` | +| `NEXT_PUBLIC_APP_URL` | Recommended | Public app URL used by metadata and links | `http://localhost:6767` | +| `QUIZAPI_KEY` | Optional | QuizAPI key for quiz generation | `qa_...` | + +Settings can also be managed inside the app at: + +```text +/settings +``` + +--- + +## Course Folder Structure + +OfflineAcademy works best when courses are organized as folders containing module folders and video files. + +```text +My_Courses/ +└── Example Course/ + ├── 01 - Introduction/ + │ └── 01 - Welcome.mp4 + ├── 02 - Core Concepts/ + │ ├── 01 - Lesson One.mp4 + │ └── 02 - Lesson Two.mp4 + └── 03 - Practice/ + └── 01 - Lab Walkthrough.mp4 +``` + +Supported video extensions include common browser-playable formats such as `.mp4`, plus additional local media formats depending on browser support. + +--- + +## Data & Persistence + +OfflineAcademy stores app data locally. + +```text +SQLite database prisma/dev.db +Course files My_Courses/ or your configured course mount +Environment .env +Quiz cache Course/module folders where generated +``` + +Important user data: + +- watch progress +- bookmarks +- bookmark notes +- course metadata +- tags and categories +- quiz records/cache +- local app settings + +--- + +## Updating + +### Local Node.js + +```bash +git pull +npm ci +npx prisma generate +npx prisma db push +npm run build +npx next start -p 6767 +``` + +### Docker + +```bash +git pull +docker compose up --build -d +``` + +--- + +## Backup + +Back up these items regularly: + +```text +prisma/dev.db Main SQLite database +.env Local configuration and optional API key +My_Courses/ Your source course library, if not backed up elsewhere +``` + +A simple backup can be as easy as copying `prisma/dev.db` somewhere safe before updates. + +--- + +## Security Notes + +OfflineAcademy is designed for trusted local networks and personal/homelab use. + +Before exposing it to the public internet, add: + +- HTTPS +- reverse proxy authentication +- network access controls +- regular database backups +- careful handling of `.env` and API keys + +Do not commit `.env`, local databases, course files, or private API keys. + +--- + +## License + +No license has been selected yet. + +That means the source is visible if this repository is public, but **all rights are reserved by default**. Other people do not automatically have permission to reuse, modify, redistribute, or sell the code. + +If you want others to freely self-host, modify, and contribute to OfflineAcademy, consider adding an open-source license such as: + +- **MIT** — simple and permissive +- **Apache-2.0** — permissive with explicit patent language +- **GPL-3.0** — requires derivative works to remain open-source + +For a public portfolio project that may become a product later, keeping the license undecided is a reasonable temporary choice. diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..97b5f1a --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,397 @@ +import type { Metadata } from 'next' +import { Header } from '@/components/Header' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { Badge } from '@/components/ui/badge' +import { + BarChart3, + Clock, + CheckCircle2, + BookOpen, + Play, + TrendingUp, + Bookmark, + Film, + Music, + FileText, + Image as ImageIcon, + Award, +} from 'lucide-react' + +export const metadata: Metadata = { + title: 'Analytics', + description: 'Learning analytics and progress overview.', +} + +interface AnalyticsData { + totalCourses: number + completedCourses: number + inProgressCourses: number + totalLessons: number + completedLessons: number + inProgressLessons: number + notStartedLessons: number + totalWatchedHours: number + totalWatchedMinutes: number + weeklyWatchedHours: number + weeklyWatchedMinutes: number + weeklyCompletedLessons: number + lessonsByType: Array<{ type: string; _count: number }> + completedByType: Record + completionRate: number + lessonCompletionRate: number +} + +async function getAnalyticsData(): Promise { + const res = await fetch('http://127.0.0.1:6767/api/progress?type=analytics', { + cache: 'no-store', + }) + if (!res.ok) { + throw new Error('Failed to fetch analytics') + } + return res.json() +} + +const lessonTypeIcons: Record = { + VIDEO: , + AUDIO: , + PDF: , + MARKDOWN: , + HTML: , + IMAGE: , + OTHER: , +} + +const lessonTypeColors: Record = { + VIDEO: 'bg-red-500/20 text-red-400', + AUDIO: 'bg-purple-500/20 text-purple-400', + PDF: 'bg-red-500/20 text-red-400', + MARKDOWN: 'bg-blue-500/20 text-blue-400', + HTML: 'bg-orange-500/20 text-orange-400', + IMAGE: 'bg-green-500/20 text-green-400', + OTHER: 'bg-muted text-muted-foreground', +} + +function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours > 0) { + return `${hours}h ${minutes}m` + } + return `${minutes}m` +} + +export const dynamic = 'force-dynamic' + +export default async function AnalyticsPage() { + const data = await getAnalyticsData() + + const totalWatchedHours = Math.floor(data.totalWatchedMinutes / 60) + const remainingMinutes = data.totalWatchedMinutes % 60 + + return ( +
+
+ +
+
+

+ Learning Analytics +

+

+ Your personal learning insights and progress overview. +

+
+ + {/* Overview Cards */} +
+ + + + + Total Courses + + + +
+

{data.totalCourses}

+

+ {data.completedCourses} completed · {data.inProgressCourses} in progress +

+ 0 ? Math.round((data.completedCourses / data.totalCourses) * 100) : 0} + className="h-1.5 mt-2 gradient-progress" + /> +
+
+ +
+
+
+ + + + + + Lessons Completed + + + +
+

{data.completedLessons}

+

+ {data.lessonCompletionRate}% completion rate +

+ +
+
+ +
+
+
+ + + + + + Total Time Watched + + + +
+

+ {totalWatchedHours}h {remainingMinutes}m +

+

{data.totalWatchedMinutes} minutes total

+
+
+ +
+
+
+ + + + + + This Week + + + +
+

+ {data.weeklyWatchedHours}h +

+

+ {data.weeklyCompletedLessons} lessons completed +

+ +
+
+ +
+
+
+
+ + {/* Detailed Breakdown */} +
+ {/* Lessons by Type */} + + + + + Lessons by Type + + + + {data.lessonsByType.map(({ type, _count }) => { + const completed = data.completedByType[type] || 0 + const percentage = _count > 0 ? Math.round((completed / _count) * 100) : 0 + const Icon = lessonTypeIcons[type] || lessonTypeIcons.OTHER + return ( +
+
+
+ + {Icon} + +
+

{type.toLowerCase()}

+

+ {completed} / {_count} completed +

+
+
+ + {percentage}% + +
+ +
+ ) + })} +
+
+ + {/* Course Progress Distribution */} + + + + + Course Progress Distribution + + + +
+
+ + + Completed + + {data.completedCourses} +
+ 0 ? Math.round((data.completedCourses / data.totalCourses) * 100) : 0} + className="h-2.5 gradient-progress" + /> +
+ + + In Progress + + {data.inProgressCourses} +
+ 0 ? Math.round((data.inProgressCourses / data.totalCourses) * 100) : 0} + className="h-2.5 gradient-progress-accent" + /> +
+ + + Not Started + + + {data.totalCourses - data.completedCourses - data.inProgressCourses} + +
+ 0 + ? Math.round(((data.totalCourses - data.completedCourses - data.inProgressCourses) / data.totalCourses) * 100) + : 0} + className="h-2.5" + /> +
+ +
+
+ + + Lesson Completion + + {data.lessonCompletionRate}% +
+ +
+
+
+
+ + {/* Time Stats */} +
+ + + + + Total Watch Time + + + +
+ Hours watched + {totalWatchedHours}h {remainingMinutes}m +
+
+ Minutes total + {data.totalWatchedMinutes} min +
+
+ Average per lesson + + {data.completedLessons > 0 + ? `${Math.round(data.totalWatchedMinutes / data.completedLessons)} min` + : '—'} + +
+
+
+ + + + + + This Week Activity + + + +
+ Hours watched + {data.weeklyWatchedHours}h +
+
+ Minutes + {data.weeklyWatchedMinutes} min +
+
+ Lessons completed + {data.weeklyCompletedLessons} +
+
+
+ + + + + + Lesson Status + + + +
+ + + Completed + + {data.completedLessons} +
+
+ + + In Progress + + {data.inProgressLessons} +
+
+ + + Not Started + + {data.notStartedLessons} +
+
+ + + Total + + {data.totalLessons} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts new file mode 100644 index 0000000..eb9f9de --- /dev/null +++ b/app/api/analytics/route.ts @@ -0,0 +1,199 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET(request: NextRequest) { + try { + const userId = 'local-user' + + // 1. Total completed lessons and total watch time (from completed lessons) + const completedLessons = await prisma.progress.findMany({ + where: { + userId, + lessonId: { not: null }, + completed: true, + }, + include: { + lesson: true, + }, + }) + + // 2. In-progress lessons (position watch time) + const inProgressLessons = await prisma.progress.findMany({ + where: { + userId, + lessonId: { not: null }, + completed: false, + position: { gt: 0 }, + }, + include: { + lesson: true, + }, + }) + + // 3. All lessons for completion stats + const allLessons = await prisma.lesson.count({ + where: { + module: { course: { hidden: false } }, + }, + }) + + // 4. Completed courses + const completedCourses = await prisma.progress.findMany({ + where: { + userId, + lessonId: null, + moduleId: null, + completed: true, + course: { hidden: false }, + }, + include: { course: true }, + }) + + // 5. In-progress courses (have lesson progress but course not completed) + const inProgressCourses = await prisma.progress.findMany({ + where: { + userId, + lessonId: { not: null }, + lesson: { module: { course: { hidden: false } } }, + }, + include: { + lesson: { + include: { + module: { include: { course: true } }, + }, + }, + }, + orderBy: { lastWatched: 'desc' }, + }) + + // 6. Bookmarks count + const totalBookmarks = await prisma.bookmark.count({ + where: { userId }, + }) + + // 7. Weekly progress - lessons completed in the last 8 weeks + const eightWeeksAgo = new Date() + eightWeeksAgo.setDate(eightWeeksAgo.getDate() - 56) + + const weeklyCompleted = await prisma.progress.groupBy({ + by: ['lastWatched'], + where: { + userId, + lessonId: { not: null }, + completed: true, + lastWatched: { gte: eightWeeksAgo }, + }, + _count: { id: true }, + }) + + // Aggregate by week + const weeklyData = new Map() + for (const wc of weeklyCompleted) { + const weekStart = new Date(wc.lastWatched) + weekStart.setDate(weekStart.getDate() - weekStart.getDay()) // Start of week (Sunday) + const weekKey = weekStart.toISOString().split('T')[0] + weeklyData.set(weekKey, (weeklyData.get(weekKey) || 0) + wc._count.id) + } + + // Ensure last 8 weeks have entries (even if 0) + const weeklyProgress: Array<{ week: string; count: number }> = [] + for (let i = 7; i >= 0; i--) { + const date = new Date() + date.setDate(date.getDate() - date.getDay() - i * 7) + const weekKey = date.toISOString().split('T')[0] + weeklyProgress.push({ + week: weekKey, + count: weeklyData.get(weekKey) || 0, + }) + } + + // Calculate totals + const completedLessonCount = completedLessons.length + const totalWatchTimeSeconds = completedLessons.reduce((sum, p) => { + return sum + (p.lesson?.duration || 0) + }, 0) + inProgressLessons.reduce((sum, p) => { + return sum + (p.position || 0) + }, 0) + + // Unique courses with progress + const courseIds = new Set() + for (const p of inProgressCourses) { + if (p.lesson?.module?.courseId) { + courseIds.add(p.lesson.module.courseId) + } + } + for (const c of completedCourses) { + if (c.courseId) courseIds.add(c.courseId) + } + + // In-progress courses with their progress + const inProgressCourseMap = new Map() + + for (const p of inProgressCourses) { + const course = p.lesson?.module?.course + if (!course) continue + const existing = inProgressCourseMap.get(course.id) + if (!existing || p.lastWatched > existing.lastWatched) { + inProgressCourseMap.set(course.id, { + courseId: course.id, + courseName: course.displayName || course.name, + courseSlug: course.slug, + completedLessons: 0, // will compute below + totalLessons: 0, + percentage: 0, + lastWatched: p.lastWatched, + }) + } + } + + // Compute progress for each in-progress course + for (const [courseId, data] of Array.from(inProgressCourseMap.entries())) { + const lessons = await prisma.lesson.findMany({ + where: { module: { courseId } }, + select: { id: true }, + }) + const completed = await prisma.progress.count({ + where: { + userId, + lessonId: { in: lessons.map(l => l.id) }, + completed: true, + }, + }) + data.totalLessons = lessons.length + data.completedLessons = completed + data.percentage = lessons.length > 0 ? Math.round((completed / lessons.length) * 100) : 0 + } + + return NextResponse.json({ + summary: { + totalWatchTimeHours: Math.round(totalWatchTimeSeconds / 3600 * 10) / 10, + totalWatchTimeMinutes: Math.round(totalWatchTimeSeconds / 60), + completedLessons: completedLessonCount, + totalLessons: allLessons, + overallCompletionRate: allLessons > 0 ? Math.round((completedLessonCount / allLessons) * 100) : 0, + completedCourses: completedCourses.length, + inProgressCourses: inProgressCourseMap.size, + totalBookmarks, + }, + weeklyProgress, + inProgressCourses: Array.from(inProgressCourseMap.values()), + completedCourses: completedCourses.map(c => ({ + courseId: c.courseId, + courseName: c.course.displayName || c.course.name, + courseSlug: c.course.slug, + completedAt: c.lastWatched, + })), + }) + } catch (error) { + console.error('Analytics fetch error:', error) + return NextResponse.json({ error: 'Failed to fetch analytics' }, { status: 500 }) + } +} diff --git a/app/api/bookmarks/[bookmarkId]/route.ts b/app/api/bookmarks/[bookmarkId]/route.ts new file mode 100644 index 0000000..98461bf --- /dev/null +++ b/app/api/bookmarks/[bookmarkId]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const updateSchema = z.object({ + note: z.string().trim().max(1000).optional().nullable(), +}) + +function normalizeNote(note?: string | null) { + const trimmed = (note || '').trim() + return trimmed.length > 0 ? trimmed : null +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ bookmarkId: string }> }) { + try { + const { bookmarkId } = await params + const body = await request.json().catch(() => ({})) + const parsed = updateSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request', details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const bookmark = await prisma.bookmark.update({ + where: { id: bookmarkId }, + data: { note: normalizeNote(parsed.data.note) }, + include: { + lesson: { + select: { id: true, title: true, slug: true }, + }, + }, + }) + + return NextResponse.json({ + success: true, + bookmark: { + ...bookmark, + note: bookmark.note ?? '', + createdAt: bookmark.createdAt.toISOString(), + updatedAt: bookmark.updatedAt.toISOString(), + }, + }) + } catch (error) { + console.error('Bookmark update error:', error) + return NextResponse.json({ error: 'Failed to update bookmark' }, { status: 500 }) + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ bookmarkId: string }> }) { + try { + const { bookmarkId } = await params + + await prisma.bookmark.delete({ where: { id: bookmarkId } }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Bookmark delete error:', error) + return NextResponse.json({ error: 'Failed to delete bookmark' }, { status: 500 }) + } +} diff --git a/app/api/bookmarks/route.ts b/app/api/bookmarks/route.ts new file mode 100644 index 0000000..1a2fa49 --- /dev/null +++ b/app/api/bookmarks/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const bookmarkSchema = z.object({ + lessonId: z.string().min(1), + courseId: z.string().min(1), + moduleId: z.string().min(1), + position: z.number().int().min(0), + note: z.string().trim().max(1000).optional().nullable(), +}) + +function normalizeNote(note?: string | null) { + const trimmed = (note || '').trim() + return trimmed.length > 0 ? trimmed : null +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const lessonId = searchParams.get('lessonId') + const courseId = searchParams.get('courseId') + + if (!lessonId && !courseId) { + return NextResponse.json({ error: 'lessonId or courseId required' }, { status: 400 }) + } + + const bookmarks = await prisma.bookmark.findMany({ + where: { + userId: 'local-user', + ...(lessonId ? { lessonId } : { courseId: courseId! }), + lesson: { + module: { + course: { hidden: false }, + }, + }, + }, + orderBy: [{ position: 'asc' }, { createdAt: 'asc' }], + include: { + lesson: { + select: { id: true, title: true, slug: true }, + }, + }, + }) + + return NextResponse.json({ + items: bookmarks.map(bookmark => ({ + ...bookmark, + note: bookmark.note ?? '', + createdAt: bookmark.createdAt.toISOString(), + updatedAt: bookmark.updatedAt.toISOString(), + })), + }) + } catch (error) { + console.error('Bookmark fetch error:', error) + return NextResponse.json({ error: 'Failed to fetch bookmarks' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})) + const parsed = bookmarkSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request', details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const bookmark = await prisma.bookmark.create({ + data: { + userId: 'local-user', + lessonId: parsed.data.lessonId, + courseId: parsed.data.courseId, + moduleId: parsed.data.moduleId, + position: parsed.data.position, + note: normalizeNote(parsed.data.note), + }, + include: { + lesson: { + select: { id: true, title: true, slug: true }, + }, + }, + }) + + return NextResponse.json({ + success: true, + bookmark: { + ...bookmark, + note: bookmark.note ?? '', + createdAt: bookmark.createdAt.toISOString(), + updatedAt: bookmark.updatedAt.toISOString(), + }, + }) + } catch (error) { + console.error('Bookmark create error:', error) + return NextResponse.json({ error: 'Failed to create bookmark' }, { status: 500 }) + } +} diff --git a/app/api/courses/[slug]/quiz/route.ts b/app/api/courses/[slug]/quiz/route.ts new file mode 100644 index 0000000..3601955 --- /dev/null +++ b/app/api/courses/[slug]/quiz/route.ts @@ -0,0 +1,171 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { ensureModuleQuizCache, syncQuizLessonFromCache, writeQuizCache } from '@/lib/quiz' +import { join, dirname } from 'path' +const QUIZ_CACHE_FILE = 'quiz_cache.json' + +async function getCourseWithModules(slug: string) { + return prisma.course.findFirst({ + where: { slug, hidden: false }, + include: { + modules: { + orderBy: { order: 'asc' }, + include: { lessons: true }, + }, + }, + }) +} + +export async function POST(request: Request, { params }: { params: Promise<{ slug: string }> }) { + try { + const { slug } = await params + const body = await request.json().catch(() => ({})) + const action = body?.action + if (action === 'regenerate') { + const course = await getCourseWithModules(slug) + if (!course) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + + const sourceValue = body?.source + const difficulty = typeof body?.difficulty === 'string' ? body.difficulty.toLowerCase() : 'medium' + const results: Array<{ module: string; status: 'ok' | 'skipped'; error?: string }> = [] + + for (const module of course.modules) { + const modulePath = join(course.path, module.name) + try { + const generated = await ensureModuleQuizCache({ + modulePath, + topic: module.name, + source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi', + force: true, + }) + + if (!generated) { + results.push({ module: module.name, status: 'skipped', error: 'Intro/local-only quiz generation skipped' }) + continue + } + + await syncQuizLessonFromCache({ + moduleId: module.id, + modulePath, + courseRoot: dirname(course.path), + topic: module.name, + source: sourceValue === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi', + lessonOrder: module.lessons.length, + autoFetch: false, + force: true, + }) + + results.push({ module: module.name, status: 'ok' }) + } catch (error) { + results.push({ module: module.name, status: 'skipped', error: String(error) }) + } + } + + return NextResponse.json({ success: true, action: 'regenerate', difficulty, results }) + } + + if (action === 'import') { + const course = await getCourseWithModules(slug) + if (!course) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + + const moduleName = typeof body?.module === 'string' ? body.module.trim() : '' + const targetModule = course.modules.find((item) => item.name === moduleName) + if (!targetModule) { + return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 }) + } + + const quiz = typeof body?.quiz === 'object' && body.quiz !== null ? (body.quiz as Record) : null + if (!quiz) { + return NextResponse.json({ error: 'Missing quiz JSON' }, { status: 400 }) + } + + const modulePath = join(course.path, targetModule.name) + const cachePath = join(modulePath, QUIZ_CACHE_FILE) + await writeQuizCache(cachePath, quiz as never) + + const synced = await syncQuizLessonFromCache({ + moduleId: targetModule.id, + modulePath, + courseRoot: dirname(course.path), + topic: targetModule.name, + source: 'the-trivia-api', + lessonOrder: targetModule.lessons.length, + autoFetch: false, + force: true, + }) + + return NextResponse.json({ + success: true, + action: 'import', + module: targetModule.name, + cachePath, + lesson: synced?.lesson || null, + }) + } + + if (action === 'clear') { + const course = await getCourseWithModules(slug) + if (!course) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + + const moduleName = typeof body?.module === 'string' ? body.module.trim() : '' + + if (moduleName === '__all__') { + const moduleIds = course.modules.map((module) => module.id) + const modulePaths = course.modules.map((module) => join(course.path, module.name, QUIZ_CACHE_FILE)) + + try { + await prisma.lesson.deleteMany({ where: { moduleId: { in: moduleIds }, slug: 'quiz' } }) + } catch { + // ignore if no quiz lessons exist + } + + try { + const { unlink } = await import('fs/promises') + await Promise.allSettled(modulePaths.map((cachePath) => unlink(cachePath).catch(() => {}))) + } catch { + // ignore missing cache files + } + + return NextResponse.json({ success: true, action: 'clear', module: '__all__' }) + } + + if (!moduleName) { + return NextResponse.json({ error: 'Missing module name' }, { status: 400 }) + } + + const targetModule = course.modules.find((item) => item.name === moduleName) + if (!targetModule) { + return NextResponse.json({ error: 'Module not found', availableModules: course.modules.map((item) => item.name) }, { status: 404 }) + } + + const modulePath = join(course.path, targetModule.name) + const cachePath = join(modulePath, QUIZ_CACHE_FILE) + + try { + await prisma.lesson.deleteMany({ where: { moduleId: targetModule.id, slug: 'quiz' } }) + } catch { + // ignore if no quiz lesson exists + } + + try { + const { unlink } = await import('fs/promises') + await unlink(cachePath).catch(() => {}) + } catch { + // ignore missing cache file + } + + return NextResponse.json({ success: true, action: 'clear', module: targetModule.name }) + } + + return NextResponse.json({ error: 'Unsupported action' }, { status: 400 }) + } catch (error) { + console.error('Course quiz batch error:', error) + return NextResponse.json({ error: 'Failed to process course quiz action' }, { status: 500 }) + } +} diff --git a/app/api/courses/[slug]/route.ts b/app/api/courses/[slug]/route.ts new file mode 100644 index 0000000..57e6d19 --- /dev/null +++ b/app/api/courses/[slug]/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server' +import { rm } from 'fs/promises' +import { resolve, sep } from 'path' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const renameSchema = z.object({ displayName: z.string().trim().min(1).max(200) }) +const tagSchema = z.object({ tagId: z.string().min(1) }) +const favoriteSchema = z.object({ favorited: z.boolean() }) + +async function getCoursesRootPath() { + const setting = await prisma.setting.findUnique({ where: { key: 'coursesRoot' } }) + return setting?.value || './My_Courses' +} + +async function findCourse(slug: string, includeHidden = false) { + return prisma.course.findFirst({ + where: includeHidden ? { slug } : { slug, hidden: false }, + select: { id: true, slug: true, name: true, displayName: true, hidden: true, path: true }, + }) +} + +function isPathWithinRoot(candidatePath: string, rootPath: string) { + const normalizedCandidate = resolve(candidatePath) + const normalizedRoot = resolve(rootPath) + return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + sep) +} + +async function enrichCourseTags(course: { id: string }) { + const connections = await prisma.courseTag.findMany({ + where: { courseId: course.id }, + include: { tag: true }, + }) + return connections.map(cn => cn.tag) +} + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + try { + const { slug } = await params + const body = await request.json().catch(() => ({})) + const rename = renameSchema.safeParse(body) + const tag = body?.tagId ? tagSchema.safeParse(body) : null + const favorite = body?.favorited !== undefined ? favoriteSchema.safeParse(body) : null + + if (!rename.success && !tag?.success && !favorite?.success) { + return NextResponse.json({ error: 'Invalid request', details: rename.error?.flatten() ?? {} }, { status: 400 }) + } + + const existing = await findCourse(slug) + if (!existing) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + + const data: Record = {} + + if (rename.success) { + data.displayName = rename.data.displayName + } + + if (favorite?.success) { + data.favorited = favorite.data.favorited + } + + const tagRecord = tag?.success + ? await prisma.tag.upsert({ + where: { name: tag.data.tagId }, + update: {}, + create: { name: tag.data.tagId }, + }) + : null + + const course = await prisma.course.update({ + where: { id: existing.id }, + data, + select: { + id: true, + slug: true, + name: true, + displayName: true, + hidden: true, + path: true, + }, + }) + + if (tagRecord) { + await prisma.courseTag.upsert({ + where: { + courseId_tagId: { + courseId: existing.id, + tagId: tagRecord.id, + }, + }, + update: {}, + create: { + courseId: existing.id, + tagId: tagRecord.id, + }, + }) + } + + const tags = await enrichCourseTags(course) + + return NextResponse.json({ success: true, course: { ...course, tags } }) + } catch (error) { + console.error('Course update error:', error) + return NextResponse.json({ error: 'Failed to update course' }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) { + try { + const { slug } = await params + const scope = (request.nextUrl.searchParams.get('scope') || 'library').toLowerCase() + + if (scope !== 'library' && scope !== 'disk' && scope !== 'tag') { + return NextResponse.json({ error: 'Invalid delete scope. Use library, disk, or tag.' }, { status: 400 }) + } + + const existing = await findCourse(slug, scope === 'disk') + if (!existing) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + + if (scope === 'disk') { + const coursesRoot = await getCoursesRootPath() + const absoluteCoursePath = resolve(existing.path) + const absoluteCoursesRoot = resolve(coursesRoot) + + if (!isPathWithinRoot(absoluteCoursePath, absoluteCoursesRoot)) { + return NextResponse.json( + { + error: 'Course path is outside the configured courses root', + details: { coursePath: absoluteCoursePath, coursesRoot: absoluteCoursesRoot }, + }, + { status: 400 } + ) + } + + await rm(absoluteCoursePath, { recursive: true, force: true }) + await prisma.course.delete({ where: { id: existing.id } }) + + return NextResponse.json({ success: true, deletedFromDisk: true, path: absoluteCoursePath }) + } + + if (scope === 'tag') { + const { tagId } = await request.json().catch(() => ({ tagId: '' })) + + if (!tagId) { + return NextResponse.json({ error: 'tagId is required when removing a tag' }, { status: 400 }) + } + + await prisma.courseTag.deleteMany({ + where: { courseId: existing.id, tagId }, + }) + + const course = await prisma.course.findUnique({ + where: { id: existing.id }, + select: { + id: true, + slug: true, + name: true, + displayName: true, + hidden: true, + path: true, + }, + }) + + const tags = course ? await enrichCourseTags(course) : [] + return NextResponse.json({ success: true, course: course ? { ...course, tags } : null }) + } + + await prisma.course.update({ + where: { id: existing.id }, + data: { + hidden: true, + displayName: null, + }, + }) + + return NextResponse.json({ success: true, deletedFromLibrary: true }) + } catch (error) { + console.error('Course delete error:', error) + return NextResponse.json({ error: 'Failed to delete course' }, { status: 500 }) + } +} diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts new file mode 100644 index 0000000..7313025 --- /dev/null +++ b/app/api/courses/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getLocalThumbnailUrl } from '@/lib/thumbnail-index-server' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = request.nextUrl + + // Pagination + const page = parseInt(searchParams.get('page') || '1') + const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100) + const skip = (page - 1) * limit + + // Search + const search = searchParams.get('search') || '' + + // Filters + const filter = searchParams.get('filter') || 'all' // all, in-progress, completed, not-started, favorites, tag: + const tag = searchParams.get('tag') || '' + const tagFilter = tag || (filter.startsWith('tag:') ? filter.slice(4) : '') + const favoritesOnly = searchParams.get('favorites') === 'true' + const sortBy = searchParams.get('sortBy') || 'updatedAt' + const sortOrder = searchParams.get('sortOrder') || 'desc' + + // Build where clause + const where: any = { hidden: false } + + if (search) { + const term = search.trim() + where.OR = [ + { name: { contains: term } }, + { displayName: { contains: term } }, + { slug: { contains: term } }, + ] + } + + if (favoritesOnly || filter === 'favorites') { + where.favorited = true + } + + if (tagFilter) { + where.courseTags = { some: { tagId: tagFilter } } + } + + if (filter === 'in-progress') { + where.progress = { + some: { userId: 'local-user', completed: false, lessonId: null }, + } + } else if (filter === 'completed') { + where.progress = { + some: { userId: 'local-user', completed: true, lessonId: null }, + } + } else if (filter === 'not-started') { + where.NOT = { + progress: { some: { userId: 'local-user', lessonId: null } }, + } + } + + // Build orderBy. Favorites are pinned above non-favorites for library organization. + const secondaryOrderBy: any = {} + if (sortBy === 'name') { + secondaryOrderBy.name = sortOrder + } else if (sortBy === 'progress') { + secondaryOrderBy.updatedAt = sortOrder + } else { + secondaryOrderBy[sortBy] = sortOrder + } + const orderBy: any[] = [{ favorited: 'desc' }, secondaryOrderBy] + + const total = await prisma.course.count({ where }) + + const courses = await prisma.course.findMany({ + where, + orderBy, + skip: Math.max(0, skip), + take: limit, + include: { + _count: { select: { modules: true } }, + progress: { + where: { userId: 'local-user', lessonId: null }, + }, + courseTags: { + include: { tag: true }, + orderBy: { tag: { name: 'asc' } }, + }, + }, + }) + + const courseIds = courses.map(c => c.id) + const lessonCounts = await prisma.lesson.groupBy({ + by: ['moduleId'], + where: { module: { courseId: { in: courseIds } } }, + _count: true, + }) + + const moduleToCourse = new Map() + const modules = await prisma.module.findMany({ + where: { courseId: { in: courseIds } }, + select: { id: true, courseId: true }, + }) + modules.forEach(m => moduleToCourse.set(m.id, m.courseId)) + + const moduleLessonCounts = new Map() + lessonCounts.forEach(lc => { + const courseId = moduleToCourse.get(lc.moduleId as string) + if (courseId) { + moduleLessonCounts.set(courseId, (moduleLessonCounts.get(courseId) || 0) + lc._count) + } + }) + + const coursesWithProgress = await Promise.all(courses.map(async (course) => { + const courseProgress = course.progress[0] + const totalLessons = moduleLessonCounts.get(course.id) || 0 + let completedLessons = 0 + let percentage = 0 + let lastWatched = null + + if (courseProgress) { + completedLessons = courseProgress.completed ? totalLessons : 0 + percentage = courseProgress.completed ? 100 : 0 + lastWatched = courseProgress.lastWatched.toISOString() + } + + return { + ...course, + _count: { ...course._count, lessons: totalLessons }, + progress: { completedLessons, totalLessons, percentage, lastWatched }, + thumbnail: await getLocalThumbnailUrl(course.slug) ?? null, + description: course.description, + tags: course.courseTags.map((connection) => connection.tag), + } + })) + + if (sortBy === 'progress') { + coursesWithProgress.sort((a, b) => + sortOrder === 'asc' + ? a.progress.percentage - b.progress.percentage + : b.progress.percentage - a.progress.percentage + ) + } + + const totalPages = Math.ceil(total / limit) + const tags = await prisma.tag.findMany({ + orderBy: { name: 'asc' }, + include: { _count: { select: { courseTags: true } } }, + }) + + return NextResponse.json({ + courses: coursesWithProgress, + tags, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }) + } catch (error) { + console.error('Courses API error:', error) + return NextResponse.json( + { error: 'Failed to fetch courses', details: String(error) }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts new file mode 100755 index 0000000..c42031a --- /dev/null +++ b/app/api/files/[...path]/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { existsSync, statSync, createReadStream } from 'fs' +import { join, resolve } from 'path' +import { getCoursesRootPath } from '@/lib/scanner' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + const { path: pathSegments } = await params + const filePath = pathSegments.join('/') + + // Security: resolve and validate path is within COURSES_ROOT + const coursesRoot = resolve(await getCoursesRootPath()) + const requestedPath = resolve(join(coursesRoot, filePath)) + + // Prevent directory traversal + if (!requestedPath.startsWith(coursesRoot)) { + return new NextResponse('Forbidden', { status: 403 }) + } + + // Helper: try to find file with alternative extensions (e.g., .svg vs .png) + const findActualFile = async (basePath: string): Promise => { + const extensions = ['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp'] + for (const ext of extensions) { + const tryPath = basePath.replace(/\.[^.]+$/, '') + ext + if (existsSync(tryPath)) return tryPath + } + return null + } + + // Check if file exists + let actualPath = requestedPath + if (!existsSync(actualPath)) { + // Try alternative extensions (for thumbnails with wrong extension requests) + const foundPath = await findActualFile(requestedPath) + if (foundPath) { + actualPath = foundPath + } else { + return new NextResponse('Not Found', { status: 404 }) + } + } + + const stats = statSync(actualPath) + + // Don't serve directories + if (stats.isDirectory()) { + return new NextResponse('Forbidden', { status: 403 }) + } + + const fileSize = stats.size + const range = request.headers.get('range') + + // Determine content type from ACTUAL file extension + const actualExt = actualPath.split('.').pop()?.toLowerCase() + const mimeTypes: Record = { + mp4: 'video/mp4', + mkv: 'video/x-matroska', + webm: 'video/webm', + mov: 'video/quicktime', + avi: 'video/x-msvideo', + mp3: 'audio/mpeg', + wav: 'audio/wav', + m4a: 'audio/mp4', + pdf: 'application/pdf', + md: 'text/markdown; charset=utf-8', + html: 'text/html; charset=utf-8', + htm: 'text/html; charset=utf-8', + json: 'application/json; charset=utf-8', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + srt: 'text/plain; charset=utf-8', + vtt: 'text/vtt; charset=utf-8', + txt: 'text/plain; charset=utf-8', + } + const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream' + + // Handle range requests (video seeking) + if (range) { + const parts = range.replace(/bytes=/, '').split('-') + const start = parseInt(parts[0], 10) + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1 + const chunkSize = end - start + 1 + + if (start >= fileSize || end >= fileSize) { + return new NextResponse(null, { + status: 416, + headers: { 'Content-Range': `bytes */${fileSize}` }, + }) + } + + const stream = createReadStream(actualPath, { start, end }) + + // Set Content-Disposition to inline for previewable file types + const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt'] + const isPreviewable = previewableTypes.some(t => contentType.includes(t)) + const contentDisposition = isPreviewable ? 'inline' : 'attachment' + + return new NextResponse(stream as any, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize.toString(), + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }) + } + + // Full file response + const stream = createReadStream(actualPath) + + // Set Content-Disposition to inline for previewable file types + const previewableTypes = ['pdf', 'markdown', 'html', 'plain', 'json', 'vtt', 'srt'] + const isPreviewable = previewableTypes.some(t => contentType.includes(t)) + const contentDisposition = isPreviewable ? 'inline' : 'attachment' + + return new NextResponse(stream as any, { + status: 200, + headers: { + 'Content-Length': fileSize.toString(), + 'Content-Type': contentType, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=31536000, immutable', + 'Content-Disposition': `${contentDisposition}; filename="${actualPath.split('/').pop()}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }) + } catch (error) { + console.error('File serve error:', error) + return new NextResponse('Internal Server Error', { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/lesson/[lessonId]/route.ts b/app/api/lesson/[lessonId]/route.ts new file mode 100755 index 0000000..4d43c8b --- /dev/null +++ b/app/api/lesson/[lessonId]/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ lessonId: string }> } +) { + try { + const { lessonId } = await params + const { searchParams } = new URL(request.url) + const courseSlug = searchParams.get('course') + + // Find the lesson with its module and progress + const lesson = await prisma.lesson.findUnique({ + where: { id: lessonId }, + include: { + module: true, + progress: { + where: { userId: 'local-user' }, + }, + }, + }) + + if (!lesson) { + return NextResponse.json({ error: 'Lesson not found' }, { status: 404 }) + } + + // Fetch the course + const course = await prisma.course.findFirst({ + where: { id: lesson.module.courseId, hidden: false }, + }) + + if (!course) { + return NextResponse.json({ error: 'Course not found' }, { status: 404 }) + } + + // Fetch modules with lessons and progress + const modules = await prisma.module.findMany({ + where: { courseId: course.id }, + orderBy: { order: 'asc' }, + include: { + lessons: { + orderBy: { order: 'asc' }, + include: { + progress: { + where: { userId: 'local-user' }, + }, + }, + }, + }, + }) + + // Fetch course-level progress, with a fallback to the latest watched lesson. + const courseProgress = await prisma.progress.findFirst({ + where: { userId: 'local-user', courseId: course.id, lessonId: null, moduleId: null }, + }) + const latestLessonProgress = courseProgress + ? null + : await prisma.progress.findFirst({ + where: { + userId: 'local-user', + courseId: course.id, + lessonId: { not: null }, + }, + orderBy: { lastWatched: 'desc' }, + }) + + // Calculate all lessons + const allLessons = modules.flatMap(m => m.lessons) + const currentIndex = allLessons.findIndex(l => l.id === lessonId) + const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null + const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null + + const totalLessons = allLessons.length + const completedLessons = allLessons.filter(l => l.progress[0]?.completed).length + const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0 + + // Build response data + const data = { + lesson: { + ...lesson, + progress: lesson.progress[0] ? { + ...lesson.progress[0], + lastWatched: lesson.progress[0].lastWatched.toISOString() + } : null, + }, + course: { + ...course, + progress: courseProgress + ? { + ...courseProgress, + lastWatched: courseProgress.lastWatched.toISOString(), + } + : latestLessonProgress + ? { + ...latestLessonProgress, + lastWatched: latestLessonProgress.lastWatched.toISOString(), + } + : null, + modules: modules.map(m => ({ + ...m, + lessons: m.lessons.map(l => ({ + ...l, + progress: l.progress[0] ? { + ...l.progress[0], + lastWatched: l.progress[0].lastWatched.toISOString() + } : null, + })), + })), + stats: { + totalLessons, + completedLessons, + percentage, + }, + }, + prevLesson, + nextLesson, + currentIndex, + totalLessons: allLessons.length, + } + + return NextResponse.json(data) + } catch (error) { + console.error('Lesson fetch error:', error) + return NextResponse.json({ error: 'Failed to fetch lesson' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts new file mode 100755 index 0000000..cf6844c --- /dev/null +++ b/app/api/progress/route.ts @@ -0,0 +1,320 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const progressSchema = z.object({ + lessonId: z.string(), + courseId: z.string(), + moduleId: z.string(), + position: z.number().min(0), + completed: z.boolean().default(false), +}) + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const parsed = progressSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 }) + } + + const { lessonId, courseId, moduleId, position, completed } = parsed.data + + const progress = await prisma.progress.upsert({ + where: { + userId_lessonId: { + userId: 'local-user', + lessonId, + }, + }, + update: { + position, + completed, + lastWatched: new Date(), + courseId, + moduleId, + }, + create: { + userId: 'local-user', + lessonId, + courseId, + moduleId, + position, + completed, + lastWatched: new Date(), + }, + }) + + // Also maintain a course-level summary row so course pages can resume correctly. + // This is stored separately from lesson progress using null lessonId/moduleId. + const totalLessons = await prisma.lesson.count({ + where: { module: { courseId } }, + }) + const completedLessons = await prisma.lesson.count({ + where: { + module: { courseId }, + progress: { some: { userId: 'local-user', completed: true } }, + }, + }) + + if (totalLessons > 0) { + const courseCompleted = completedLessons === totalLessons + const courseProgressData = { + userId: 'local-user', + courseId, + lessonId: null, + moduleId: null, + position, + completed: courseCompleted, + lastWatched: new Date(), + } + + const existingCourseProgress = await prisma.progress.findFirst({ + where: { + userId: 'local-user', + courseId, + lessonId: null, + moduleId: null, + }, + }) + + if (existingCourseProgress) { + await prisma.progress.update({ + where: { id: existingCourseProgress.id }, + data: courseProgressData, + }) + } else { + await prisma.progress.create({ + data: courseProgressData, + }) + } + } + + return NextResponse.json({ success: true, progress }) + } catch (error) { + console.error('Progress save error:', error) + return NextResponse.json({ error: 'Failed to save progress' }, { status: 500 }) + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const lessonId = searchParams.get('lessonId') + const courseId = searchParams.get('courseId') + const type = searchParams.get('type') // 'continue' or 'completed' + + if (type === 'continue') { + // Get in-progress lessons + const progressRecords = await prisma.progress.findMany({ + where: { + userId: 'local-user', + lessonId: { not: null }, + completed: false, + lesson: { module: { course: { hidden: false } } }, + }, + orderBy: { lastWatched: 'desc' }, + take: 20, + include: { + lesson: { + include: { + module: { + include: { + course: true, + }, + }, + }, + }, + }, + }) + + const items = progressRecords + .filter(p => p.lesson) + .map(p => ({ + progress: { + ...p, + lastWatched: p.lastWatched.toISOString(), + }, + lesson: p.lesson!, + course: p.lesson!.module.course, + module: p.lesson!.module, + })) + + return NextResponse.json({ items }) + } + + if (type === 'completed') { + // Get completed courses + const completedProgress = await prisma.progress.findMany({ + where: { + userId: 'local-user', + lessonId: null, + moduleId: null, + completed: true, + course: { hidden: false }, + }, + include: { + course: true, + }, + }) + + const items = completedProgress.map(p => p.course) + + return NextResponse.json({ items }) + } + + if (type === 'analytics') { + // Get analytics data for dashboard + const allProgress = await prisma.progress.findMany({ + where: { + userId: 'local-user', + course: { hidden: false }, + }, + }) + + const completedLessons = allProgress.filter(p => p.lessonId && p.completed) + const inProgressLessons = allProgress.filter(p => p.lessonId && !p.completed && p.position > 0) + const notStartedCount = await prisma.lesson.count({ + where: { + module: { course: { hidden: false } }, + progress: { none: { userId: 'local-user' } }, + }, + }) + + const totalCourses = await prisma.course.count({ where: { hidden: false } }) + const completedCourses = await prisma.progress.count({ + where: { + userId: 'local-user', + lessonId: null, + moduleId: null, + completed: true, + course: { hidden: false }, + }, + }) + const inProgressCourses = await prisma.progress.count({ + where: { + userId: 'local-user', + lessonId: null, + moduleId: null, + completed: false, + course: { hidden: false }, + }, + }) + + // Calculate total watched time (in seconds) + const totalWatchedSeconds = allProgress.reduce((sum, p) => sum + (p.position || 0), 0) + + // Get weekly activity (last 7 days) + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + const recentProgress = allProgress.filter(p => new Date(p.lastWatched) >= sevenDaysAgo) + const weeklyWatchedSeconds = recentProgress.reduce((sum, p) => sum + (p.position || 0), 0) + const weeklyCompleted = recentProgress.filter(p => p.lessonId && p.completed).length + + // Lessons by type + const lessonsByType = await prisma.lesson.groupBy({ + by: ['type'], + _count: true, + where: { module: { course: { hidden: false } } }, + }) + + // Completed lessons by type + const completedByType: Record = {} + for (const p of completedLessons) { + if (!p.lessonId) continue + const lesson = await prisma.lesson.findUnique({ where: { id: p.lessonId }, select: { type: true } }) + if (lesson) { + completedByType[lesson.type] = (completedByType[lesson.type] || 0) + 1 + } + } + + return NextResponse.json({ + totalCourses, + completedCourses, + inProgressCourses, + totalLessons: completedLessons.length + inProgressLessons.length + notStartedCount, + completedLessons: completedLessons.length, + inProgressLessons: inProgressLessons.length, + notStartedLessons: notStartedCount, + totalWatchedHours: Math.round(totalWatchedSeconds / 3600 * 10) / 10, + totalWatchedMinutes: Math.round(totalWatchedSeconds / 60), + weeklyWatchedHours: Math.round(weeklyWatchedSeconds / 3600 * 10) / 10, + weeklyWatchedMinutes: Math.round(weeklyWatchedSeconds / 60), + weeklyCompletedLessons: weeklyCompleted, + lessonsByType, + completedByType, + completionRate: totalCourses > 0 ? Math.round((completedCourses / totalCourses) * 100) : 0, + lessonCompletionRate: (completedLessons.length + inProgressLessons.length + notStartedCount) > 0 + ? Math.round((completedLessons.length / (completedLessons.length + inProgressLessons.length + notStartedCount)) * 100) + : 0, + }) + } + + if (lessonId) { + const progress = await prisma.progress.findUnique({ + where: { + userId_lessonId: { + userId: 'local-user', + lessonId, + }, + }, + }) + if (progress) { + return NextResponse.json({ + progress: { + ...progress, + lastWatched: progress.lastWatched.toISOString() + } + }) + } + return NextResponse.json({ progress: null }) + } + + if (courseId) { + const progress = await prisma.progress.findFirst({ + where: { + userId: 'local-user', + courseId, + lessonId: null, + moduleId: null, + course: { hidden: false }, + }, + }) + + if (progress) { + return NextResponse.json({ + progress: { + ...progress, + lastWatched: progress.lastWatched.toISOString(), + }, + }) + } + + const latestLessonProgress = await prisma.progress.findFirst({ + where: { + userId: 'local-user', + courseId, + lessonId: { not: null }, + course: { hidden: false }, + }, + orderBy: { lastWatched: 'desc' }, + }) + + if (latestLessonProgress) { + return NextResponse.json({ + progress: { + ...latestLessonProgress, + lastWatched: latestLessonProgress.lastWatched.toISOString(), + }, + }) + } + + return NextResponse.json({ progress: null }) + } + + return NextResponse.json({ error: 'lessonId, courseId or type required' }, { status: 400 }) + } catch (error) { + console.error('Progress fetch error:', error) + return NextResponse.json({ error: 'Failed to fetch progress' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/quiz/attempt/route.ts b/app/api/quiz/attempt/route.ts new file mode 100644 index 0000000..06be66e --- /dev/null +++ b/app/api/quiz/attempt/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const requestSchema = z.object({ + lessonId: z.string().min(1), + courseId: z.string().min(1), + moduleId: z.string().min(1), + score: z.number().int().min(0), + totalQuestions: z.number().int().positive(), + passed: z.boolean(), +}) + +export async function POST(request: Request) { + try { + const body = await request.json() + const parsed = requestSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request', details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const { lessonId, courseId, moduleId, score, totalQuestions, passed } = parsed.data + + const quizAttempt = await prisma.quizAttempt.upsert({ + where: { + userId_lessonId: { + userId: 'local-user', + lessonId, + }, + }, + update: { + score, + passed, + completed: passed, + }, + create: { + userId: 'local-user', + lessonId, + score, + passed, + completed: passed, + }, + }) + + const progress = await prisma.progress.upsert({ + where: { + userId_lessonId: { + userId: 'local-user', + lessonId, + }, + }, + update: { + courseId, + moduleId, + completed: passed, + position: passed ? totalQuestions : score, + lastWatched: new Date(), + }, + create: { + userId: 'local-user', + lessonId, + courseId, + moduleId, + completed: passed, + position: passed ? totalQuestions : score, + lastWatched: new Date(), + }, + }) + + return NextResponse.json({ success: true, quizAttempt, progress }) + } catch (error) { + console.error('Quiz attempt error:', error) + return NextResponse.json({ error: 'Failed to save quiz attempt' }, { status: 500 }) + } +} diff --git a/app/api/quiz/route.ts b/app/api/quiz/route.ts new file mode 100644 index 0000000..ca36d94 --- /dev/null +++ b/app/api/quiz/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { ensureModuleQuizCache, syncQuizLessonFromCache, shouldSkipQuizGeneration } from '@/lib/quiz' +import type { QuizSource } from '@/lib/quiz-types' +import { z } from 'zod' +import { join, dirname } from 'path' + +const requestSchema = z.object({ + lessonId: z.string().min(1), + topic: z.string().min(1).optional(), + source: z.enum(['quizapi', 'the-trivia-api']).optional(), + force: z.boolean().optional(), +}) + +async function getQuizSource(): Promise { + const setting = await prisma.setting.findUnique({ where: { key: 'quizApiSource' } }) + return setting?.value === 'the-trivia-api' ? 'the-trivia-api' : 'quizapi' +} + +export async function POST(request: Request) { + try { + const body = await request.json() + const parsed = requestSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request', details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const lesson = await prisma.lesson.findUnique({ + where: { id: parsed.data.lessonId }, + include: { + module: { + include: { + course: true, + lessons: { + select: { id: true }, + }, + }, + }, + }, + }) + + if (!lesson) { + return NextResponse.json({ error: 'Lesson not found' }, { status: 404 }) + } + + if (!lesson.module.course.path) { + return NextResponse.json({ error: 'Course path missing' }, { status: 400 }) + } + + const source = parsed.data.source || await getQuizSource() + const topic = parsed.data.topic?.trim() || lesson.module.name + const modulePath = join(lesson.module.course.path, lesson.module.name) + const moduleShouldSkip = await shouldSkipQuizGeneration(modulePath, topic) + if (moduleShouldSkip) { + return NextResponse.json({ error: 'Quiz generation skipped for this module' }, { status: 400 }) + } + + const cache = await ensureModuleQuizCache({ + modulePath, + topic, + source, + force: parsed.data.force ?? Boolean(parsed.data.topic), + }) + + if (!cache) { + return NextResponse.json({ error: 'Failed to generate quiz cache' }, { status: 500 }) + } + + const synced = await syncQuizLessonFromCache({ + moduleId: lesson.moduleId, + modulePath, + courseRoot: dirname(lesson.module.course.path), + topic, + source, + autoFetch: false, + force: false, + }) + + return NextResponse.json({ ...cache, lesson: synced?.lesson || null }) + } catch (error) { + console.error('Quiz API error:', error) + return NextResponse.json({ error: 'Failed to generate quiz' }, { status: 500 }) + } +} diff --git a/app/api/scan/route.ts b/app/api/scan/route.ts new file mode 100755 index 0000000..ade634a --- /dev/null +++ b/app/api/scan/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { scanCoursesDirectory, scanCoursesFull } from '@/lib/scanner' + +async function getQuizScanSettings() { + const settings = await prisma.setting.findMany({ + where: { key: { in: ['autoFetchQuizzes', 'quizApiSource'] } }, + }) + + const settingsMap = Object.fromEntries(settings.map((setting) => [setting.key, setting.value])) + return { + autoFetchQuizzes: settingsMap.autoFetchQuizzes === 'true', + quizApiSource: settingsMap.quizApiSource === 'the-trivia-api' + ? 'the-trivia-api' + : 'quizapi', + } as const +} + +export async function POST(request: NextRequest) { + const startTime = Date.now() + + try { + const body = await request.json().catch(() => ({})) + const isFullScan = body.fullScan === true + const quizSettings = await getQuizScanSettings() + + const result = isFullScan + ? await scanCoursesFull(quizSettings) + : await scanCoursesDirectory(quizSettings) + + const duration = Date.now() - startTime + + return NextResponse.json({ + ...result, + duration, + success: true, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('Scan API error:', error) + return NextResponse.json( + { error: 'Scan failed', details: String(error), success: false }, + { status: 500 } + ) + } +} + +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed. Use POST to trigger scan.' }, + { status: 405 } + ) +} \ No newline at end of file diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100755 index 0000000..72c6de4 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const SETTINGS_KEYS = ['coursesRoot', 'autoFetchQuizzes', 'quizApiSource', 'quizApiKey'] as const +const SETTINGS_KEYS_ARRAY: string[] = [...SETTINGS_KEYS] + +const singleSettingSchema = z.object({ + key: z.enum(['coursesRoot', 'autoFetchQuizzes', 'quizApiSource', 'quizApiKey']), + value: z.union([z.string(), z.boolean()]), +}) + +const bulkSettingsSchema = z.object({ + coursesRoot: z.string().min(1).optional(), + autoFetchQuizzes: z.union([z.boolean(), z.string()]).optional(), + quizApiSource: z.enum(['the-trivia-api', 'quizapi']).optional(), + quizApiKey: z.string().optional(), +}) + +export async function GET() { + try { + const settings = await prisma.setting.findMany({ + where: { key: { in: SETTINGS_KEYS_ARRAY } }, + }) + + const settingsMap: Record = {} + for (const s of settings) { + settingsMap[s.key] = s.value + } + + // Default coursesRoot if not set + if (!settingsMap.coursesRoot) { + settingsMap.coursesRoot = './My_Courses' + } + + if (!settingsMap.autoFetchQuizzes) { + settingsMap.autoFetchQuizzes = 'false' + } + + if (!settingsMap.quizApiSource) { + settingsMap.quizApiSource = 'quizapi' + } + + // Always include quizApiKey in response (empty string if not set) + settingsMap.quizApiKey = settingsMap.quizApiKey || '' + console.log('[DEBUG] Settings response:', JSON.stringify(settingsMap)) + + return NextResponse.json(settingsMap) + } catch (error) { + console.error('Settings fetch error:', error) + return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 }) + } +} + +export async function POST(request: Request) { + try { + const body = await request.json() + + const singleParsed = singleSettingSchema.safeParse(body) + if (singleParsed.success) { + const { key, value } = singleParsed.data + const normalizedValue = typeof value === 'boolean' ? String(value) : value + + const setting = await prisma.setting.upsert({ + where: { key }, + update: { value: normalizedValue }, + create: { key, value: normalizedValue }, + }) + + return NextResponse.json({ success: true, setting }) + } + + const bulkParsed = bulkSettingsSchema.safeParse(body) + if (!bulkParsed.success) { + return NextResponse.json( + { error: 'Invalid request', details: bulkParsed.error.flatten() }, + { status: 400 } + ) + } + + const entries = Object.entries(bulkParsed.data).filter(([, value]) => value !== undefined) + + if (entries.length === 0) { + return NextResponse.json({ error: 'No settings provided' }, { status: 400 }) + } + + const savedSettings = [] + for (const [key, value] of entries) { + const normalizedValue = typeof value === 'boolean' ? String(value) : value + const setting = await prisma.setting.upsert({ + where: { key }, + update: { value: normalizedValue }, + create: { key, value: normalizedValue }, + }) + savedSettings.push(setting) + } + + return NextResponse.json({ success: true, settings: savedSettings }) + } catch (error) { + console.error('Settings update error:', error) + return NextResponse.json({ error: 'Failed to update setting' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/course/[slug]/CourseActions.tsx b/app/course/[slug]/CourseActions.tsx new file mode 100644 index 0000000..036eea0 --- /dev/null +++ b/app/course/[slug]/CourseActions.tsx @@ -0,0 +1,392 @@ +'use client' + +import * as React from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { + ArrowLeft, + RotateCcw, + Share2, + Settings, + BrainCircuit, + Loader2, + Star, + Tags, + FolderPlus, + PencilLine, + Trash2, + Bomb, + CheckCircle2, + AlertCircle, + X, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' +import { Badge } from '@/components/ui/badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +type CourseActionsProps = { + course: { + id: string + slug: string + name: string + path: string + favorited?: boolean + } + modules: any[] +} + +export function CourseActions({ course, modules }: CourseActionsProps) { + const router = useRouter() + const [menuOpen, setMenuOpen] = React.useState(false) + const [loading, setLoading] = React.useState(false) + const [status, setStatus] = React.useState<{ kind: 'success' | 'error'; text: string } | null>(null) + const [quickAction, setQuickAction] = React.useState<'tag' | 'category' | null>(null) + const [quickActionValue, setQuickActionValue] = React.useState('') + const [quickActionError, setQuickActionError] = React.useState(null) + const [quizStatus, setQuizStatus] = React.useState<'idle' | 'running' | 'success' | 'error'>('idle') + const [quizMessage, setQuizMessage] = React.useState(null) + const [quizResults, setQuizResults] = React.useState<{ ok: number; skipped: number } | null>(null) + + const refreshLibrary = async () => { + router.refresh() + } + + const patchCourse = async (payload: Record) => { + const response = await fetch(`/api/courses/${course.slug}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data?.error || 'Failed to update course') + } + + await refreshLibrary() + } + + const handleToggleFavorite = async () => { + try { + await patchCourse({ favorited: !course.favorited }) + } catch (error) { + console.error('Toggle favorite failed:', error) + setStatus({ kind: 'error', text: 'Sorry, the pin did not stick. The favorite goblin dropped it.' }) + } + } + + const startQuickAction = (mode: 'tag' | 'category') => { + setQuickAction(mode) + setQuickActionValue('') + setQuickActionError(null) + } + + const submitQuickAction = async () => { + if (!quickAction) return + const trimmed = quickActionValue.trim().replace(/\s+/g, ' ') + if (!trimmed) { + setQuickActionError(quickAction === 'tag' ? 'Type a tag first.' : 'Type a category first.') + return + } + + try { + await patchCourse({ tagId: quickAction === 'category' ? `category:${trimmed}` : trimmed }) + setQuickAction(null) + setQuickActionValue('') + setQuickActionError(null) + } catch (error) { + console.error(`${quickAction} add failed:`, error) + setQuickActionError(quickAction === 'tag' ? 'Tag did not stick. Try again.' : 'Category did not stick. Try again.') + } + } + + const [clearQuizModule, setClearQuizModule] = React.useState('') + + const [clearQuizMode, setClearQuizMode] = React.useState(false) + + const clearQuiz = async () => { + if (!clearQuizModule) { + setStatus({ kind: 'error', text: 'Pick a module to clear first.' }) + return + } + + const confirmed = window.confirm(`Remove quiz cache and quiz lesson for module "${clearQuizModule}"? This cannot be undone.`) + if (!confirmed) return + + setQuizStatus('running') + setQuizMessage('Clearing quiz...') + setClearQuizMode(false) + setClearQuizModule('') + try { + const response = await fetch(`/api/courses/${course.slug}/quiz`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'clear', module: clearQuizModule }), + }) + + const data = await response.json() + if (!response.ok) { + throw new Error(data?.error || 'Failed to clear quiz') + } + + await refreshLibrary() + router.refresh() + setQuizStatus('success') + setQuizMessage(`✅ Cleared quiz for ${clearQuizModule}`) + setQuizResults(null) + } catch (error) { + console.error('Clear quiz failed:', error) + setQuizStatus('error') + setQuizMessage(error instanceof Error ? error.message : 'Failed to clear quiz.') + } + } + + const handleRegenerateQuiz = async () => { + setQuizStatus('running') + setQuizMessage('Regenerating quizzes with QuizAPI...') + try { + const response = await fetch(`/api/courses/${course.slug}/quiz`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'regenerate', source: 'quizapi', difficulty: 'medium' }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data?.error || 'Failed to regenerate quizzes') + } + + const okCount = data.results?.filter((item: { status: string }) => item.status === 'ok').length ?? 0 + const skippedCount = data.results?.filter((item: { status: string }) => item.status === 'skipped').length ?? 0 + setQuizResults({ ok: okCount, skipped: skippedCount }) + await refreshLibrary() + router.refresh() + setQuizStatus('success') + setQuizMessage(`✅ Regenerated ${okCount} module quiz${okCount !== 1 ? 's' : ''}${skippedCount ? ` (${skippedCount} skipped)` : ''}`) + } catch (error) { + console.error('Regenerate quiz failed:', error) + setQuizStatus('error') + setQuizMessage(error instanceof Error ? error.message : 'Quiz regeneration failed.') + } + } + + const handleRename = async () => { + const nextName = window.prompt('Rename course', course.name) + if (nextName === null) return + + const trimmed = nextName.trim() + if (!trimmed || trimmed === course.name) return + + try { + const response = await fetch(`/api/courses/${course.slug}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName: trimmed }), + }) + + if (!response.ok) { + throw new Error('Failed to rename course') + } + + await refreshLibrary() + } catch (error) { + console.error('Rename course failed:', error) + setStatus({ kind: 'error', text: 'Sorry, the rename did not stick. The little gremlin in the API tripped.' }) + } + } + + const handleDelete = async (scope: 'library' | 'disk') => { + const isDiskDelete = scope === 'disk' + const confirmationMessage = isDiskDelete + ? `Delete "${course.name}" from disk?\n\nThis permanently removes the course folder and all library records. This cannot be undone.` + : `Delete "${course.name}" from the library?\n\nThis hides the course from the app but keeps its files on disk.` + + const confirmed = window.confirm(confirmationMessage) + if (!confirmed) return + + try { + const response = await fetch(`/api/courses/${course.slug}?scope=${scope}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error(`Failed to delete course (${scope})`) + } + + await refreshLibrary() + // Navigate back to library after deletion + router.push('/') + } catch (error) { + console.error('Delete course failed:', error) + setStatus({ kind: 'error', text: isDiskDelete + ? 'Sorry, the disk delete did not stick. The server bumped into the filesystem goblin.' + : 'Sorry, the library delete did not stick. The server sneezed on the request.' }) + } + } + + return ( +
+
+ + + Back to Library + +
+ + + + + + + Support on Ko-fi + + + + + + + + + + {course.favorited ? 'Unpin favorite' : 'Pin favorite'} + + { event.preventDefault(); startQuickAction('tag') }} className="gap-2"> + + Add tag + + { event.preventDefault(); startQuickAction('category') }} className="gap-2"> + + Add category + + {quickAction && ( +
event.stopPropagation()}> + + setQuickActionValue(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Enter') submitQuickAction() + if (event.key === 'Escape') setQuickAction(null) + }} + placeholder={quickAction === 'tag' ? 'e.g. linux' : 'e.g. Cloud'} + className="mb-2 h-8 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus:ring-2 focus:ring-primary/40" + autoFocus + /> + {quickActionError &&

{quickActionError}

} +
+ + +
+
+ )} + { event.preventDefault(); setClearQuizMode(value => !value) }} disabled={quizStatus === 'running'} className="gap-2"> + {quizStatus === 'running' ? : } + {clearQuizMode ? 'Cancel' : 'Clear Quiz'} + + {clearQuizMode && ( +
event.stopPropagation()}> + + +
+ + +
+
+ )} + + + {quizStatus === 'running' ? : } + {quizStatus === 'running' ? 'Regenerating...' : 'Regenerate Quiz'} + + + + + Rename + + + handleDelete('library')} className="gap-2 text-destructive focus:text-destructive"> + + Delete from library + + handleDelete('disk')} className="gap-2 text-destructive focus:text-destructive"> + + Delete from disk + +
+
+ + {status ? {status.text} : null} +
+
+ + {(quizStatus === 'success' || quizStatus === 'error') && quizMessage && ( +
+ {quizMessage} + {quizResults && ( + + {quizResults.ok} generated + {quizResults.skipped > 0 && ·} + {quizResults.skipped > 0 && {quizResults.skipped} skipped} + + )} + +
+ )} + + {loading || quizStatus === 'running' ? : null} +
+ ) +} diff --git a/app/course/[slug]/page.tsx b/app/course/[slug]/page.tsx new file mode 100755 index 0000000..af4f3fe --- /dev/null +++ b/app/course/[slug]/page.tsx @@ -0,0 +1,173 @@ +import Link from 'next/link' +import { CourseActions } from './CourseActions' +import { ModuleAccordion } from '@/components/ModuleAccordion' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { Play, BookOpen } from 'lucide-react' +import { getCourseDisplayName } from '@/lib/course-display' +import { formatDuration } from '@/lib/utils' +import { prisma } from '@/lib/prisma' + +interface CoursePageProps { + params: Promise<{ slug: string }> +} + +async function getCourse(slug: string) { + const course = await prisma.course.findFirst({ + where: { slug, hidden: false }, + include: { + modules: { + orderBy: { order: 'asc' }, + include: { + lessons: { + orderBy: { order: 'asc' }, + include: { + progress: { + where: { userId: 'local-user' }, + take: 1, + }, + }, + }, + }, + }, + }, + }) + + if (!course) return null + + const modules = course.modules.map((module) => ({ + ...module, + lessons: module.lessons.map((lesson) => ({ + ...lesson, + progress: lesson.progress[0] + ? { + completed: lesson.progress[0].completed, + position: lesson.progress[0].position, + lastWatched: lesson.progress[0].lastWatched.toISOString(), + } + : null, + })), + })) + + const allLessons = modules.flatMap((module) => module.lessons) + const completedLessons = allLessons.filter((lesson) => lesson.progress?.completed).length + const totalLessons = allLessons.length + const totalDuration = allLessons.reduce((sum, lesson) => sum + (lesson.duration || 0), 0) + const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0 + const latestProgress = await prisma.progress.findFirst({ + where: { userId: 'local-user', courseId: course.id, lessonId: { not: null } }, + orderBy: { lastWatched: 'desc' }, + select: { lessonId: true }, + }) + + return { + ...course, + modules, + stats: { + totalLessons, + completedLessons, + totalDuration, + percentage, + lastLessonId: latestProgress?.lessonId || null, + }, + } +} + +export default async function CoursePage({ params }: CoursePageProps) { + const { slug } = await params + const course = await getCourse(slug) + if (!course) return null + + const courseTitle = getCourseDisplayName(course) + const modules = course.modules || [] + + return ( +
+ + +
+
+
+
+ {course.thumbnail ? ( + {courseTitle} + ) : ( +
+ +
+ )} +
+
+
+ {modules.length} modules + {course.stats.totalLessons} lessons + {formatDuration(course.stats.totalDuration)} +
+

{courseTitle}

+ {course.description &&

{course.description}

} +
+
+ {course.stats.completedLessons} / {course.stats.totalLessons} lessons completed + {course.stats.percentage}% +
+ +
+
+
+
+
+ +
+ + + + + Course Content + + + +
+ +
+
+
+ +
+ + + {course.stats.lastLessonId ? ( + <> + +

Ready to continue?

+

You left off at Lesson {(modules.flatMap((module: any) => module.lessons).find((lesson: any) => lesson.id === course.stats.lastLessonId)?.order ?? 0) + 1}

+ + + + + ) : ( + <> + +

Start Learning

+

Select a lesson from the sidebar to begin.

+ {modules[0]?.lessons[0] && ( + + + + )} + + )} +
+
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100755 index 0000000..6c937dc --- /dev/null +++ b/app/globals.css @@ -0,0 +1,346 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 220 25% 4%; + --background-elevated: 220 20% 6%; + --background-glass: 220 20% 8% / 0.7; + --foreground: 210 40% 96%; + --foreground-muted: 215 25% 65%; + --foreground-subtle: 215 20% 45%; + --card: 220 20% 7% / 0.8; + --card-foreground: 210 40% 96%; + --card-border: 180 50% 25% / 0.3; + --card-border-hover: 160 100% 45% / 0.5; + --popover: 220 20% 6%; + --popover-foreground: 210 40% 98%; + --primary: 160 100% 42%; + --primary-glow: 160 100% 42% / 0.4; + --primary-foreground: 220 25% 4%; + --primary-muted: 160 100% 42% / 0.15; + --accent: 45 100% 58%; + --accent-glow: 45 100% 58% / 0.4; + --accent-foreground: 220 25% 4%; + --accent-muted: 45 100% 58% / 0.15; + --secondary: 220 15% 12%; + --secondary-foreground: 210 40% 96%; + --secondary-border: 220 15% 18%; + --muted: 220 15% 10%; + --muted-foreground: 215 20% 55%; + --destructive: 0 75% 55%; + --destructive-foreground: 210 40% 98%; + --border: 220 15% 18%; + --input: 220 15% 12%; + --ring: 160 100% 42%; + --radius: 0.75rem; + --radius-sm: 0.5rem; + --radius-lg: 1rem; + --radius-xl: 1.5rem; + } + + .light { + --background: 220 25% 97%; + --background-elevated: 220 20% 100%; + --background-glass: 220 20% 98% / 0.7; + --foreground: 220 25% 4%; + --foreground-muted: 215 25% 35%; + --foreground-subtle: 215 20% 25%; + --card: 220 20% 93% / 0.8; + --card-foreground: 220 25% 4%; + --card-border: 180 50% 75% / 0.3; + --card-border-hover: 160 100% 35% / 0.5; + --popover: 220 20% 100%; + --popover-foreground: 220 25% 4%; + --primary: 160 100% 25%; + --primary-glow: 160 100% 30% / 0.4; + --primary-foreground: 220 25% 4%; + --primary-muted: 160 100% 90% / 0.15; + --accent: 45 100% 38%; + --accent-glow: 45 100% 40% / 0.4; + --accent-foreground: 220 25% 4%; + --accent-muted: 45 100% 90% / 0.15; + --secondary: 220 15% 88%; + --secondary-foreground: 220 25% 4%; + --secondary-border: 220 15% 80%; + --muted: 220 15% 90%; + --muted-foreground: 215 20% 35%; + --destructive: 0 75% 45%; + --destructive-foreground: 210 40% 98%; + --border: 220 15% 80%; + --input: 220 15% 88%; + --ring: 160 100% 30%; + } +} + +@layer base { + * { @apply border-border; } + html { @apply scroll-smooth; } + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + background-image: radial-gradient(ellipse 80% 50% at 50% -20%, hsl(var(--background-elevated) / 0.6), transparent); + background-attachment: fixed; + } + html.light body { + background-image: radial-gradient(ellipse 80% 50% at 50% -20%, hsl(220 25% 92% / 0.5), transparent); + } + ::selection { @apply bg-primary/30 text-primary-foreground; } +} + +@layer components { + /* Border color utilities for @apply */ + .border-secondary-border { border-color: hsl(var(--secondary-border)); } + .border-primary\/20 { border-color: hsl(var(--primary) / 0.2); } + .border-primary\/30 { border-color: hsl(var(--primary) / 0.3); } + .border-green-500\/30 { border-color: hsl(140 100% 30% / 0.3); } + .border-emerald-500\/30 { border-color: hsl(160 100% 30% / 0.3); } + .border-accent\/30 { border-color: hsl(var(--accent) / 0.3); } + .border-white\/5 { border-color: hsl(0 0% 100% / 0.05); } + .border-white\/10 { border-color: hsl(0 0% 100% / 0.1); } + .border-white\/20 { border-color: hsl(0 0% 100% / 0.2); } + .border-black\/60 { border-color: hsl(0 0% 0% / 0.6); } + + /* Background color utilities */ + .bg-secondary-border { background-color: hsl(var(--secondary-border)); } + .bg-primary-muted { background-color: hsl(var(--primary-muted)); } + .bg-accent-muted { background-color: hsl(var(--accent-muted)); } + .bg-card-border { background-color: hsl(var(--card-border)); } + .bg-primary\/10 { background-color: hsl(var(--primary) / 0.1); } + .bg-primary\/15 { background-color: hsl(var(--primary) / 0.15); } + .bg-accent\/10 { background-color: hsl(var(--accent) / 0.1); } + .bg-accent\/15 { background-color: hsl(var(--accent) / 0.15); } + .bg-green-500\/15 { background-color: hsl(140 100% 30% / 0.15); } + .bg-emerald-500\/15 { background-color: hsl(160 100% 30% / 0.15); } + .bg-blue-500\/15 { background-color: hsl(220 100% 50% / 0.15); } + .bg-primary\/10 { background-color: hsl(var(--primary) / 0.1); } + .bg-white\/5 { background-color: hsl(0 0% 100% / 0.05); } + .bg-black\/60 { background-color: hsl(0 0% 0% / 0.6); } + .bg-primary\/90 { background-color: hsl(var(--primary) / 0.9); } + .bg-accent\/90 { background-color: hsl(var(--accent) / 0.9); } + .bg-primary\/20 { background-color: hsl(var(--primary) / 0.2); } + + /* Text color utilities */ + .text-foreground-muted { color: hsl(var(--foreground-muted)); } + .text-foreground-subtle { color: hsl(var(--foreground-subtle)); } + .text-muted-foreground { color: hsl(var(--muted-foreground)); } + .text-primary-foreground { color: hsl(var(--primary-foreground)); } + .text-accent-foreground { color: hsl(var(--accent-foreground)); } + .text-primary { color: hsl(var(--primary)); } + .text-accent { color: hsl(var(--accent)); } + .text-emerald-400 { color: hsl(160 100% 45%); } + .text-cyan-400 { color: hsl(180 100% 50%); } + .text-green-500 { color: hsl(140 100% 40%); } + + .glass-card { + @apply relative rounded-xl border bg-card/60 backdrop-blur-xl; + border-color: hsl(var(--card-border)); + box-shadow: + 0 4px 24px -4px hsl(var(--background) / 0.5), + 0 0 0 1px hsl(var(--card-border)) inset, + 0 1px 2px -1px hsl(var(--background) / 0.3) inset; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + .glass-card:hover { + border-color: hsl(var(--card-border-hover)); + box-shadow: + 0 8px 32px -8px hsl(var(--primary) / 0.2), + 0 0 0 1px hsl(var(--card-border-hover)) inset, + 0 1px 2px -1px hsl(var(--background) / 0.3) inset; + transform: translateY(-2px); + } + .glass-card-elevated { + @apply glass-card; + box-shadow: + 0 12px 48px -12px hsl(var(--background) / 0.6), + 0 0 0 1px hsl(var(--card-border)) inset, + 0 4px 16px -4px hsl(var(--background) / 0.4) inset; + } + + .btn-premium { + @apply relative inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50; + } + .btn-premium-primary { + @apply btn-premium bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]; + } + .btn-premium-primary:hover { + box-shadow: 0 0 25px -5px hsl(var(--primary-glow)); + } + .btn-premium-secondary { + @apply btn-premium bg-secondary text-secondary-foreground border border-secondary-border hover:bg-secondary/80 hover:border-border hover:shadow-lg; + } + .btn-premium-ghost { + @apply btn-premium bg-transparent hover:bg-secondary text-foreground hover:text-primary hover:border-primary/30 border border-transparent; + } + .btn-premium-accent { + @apply btn-premium bg-accent text-accent-foreground hover:bg-accent/90 active:scale-[0.98]; + } + .btn-premium-accent:hover { + box-shadow: 0 0 25px -5px hsl(var(--accent-glow)); + } + .btn-premium-icon { @apply btn-premium p-2 rounded-lg; } + .btn-premium-icon-sm { @apply btn-premium p-1.5 rounded-lg text-xs; } + + .header-control { + @apply relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-foreground-muted transition-all duration-200 hover:text-foreground hover:bg-secondary hover:border-primary/20 border border-transparent; + } + .header-control:hover { + box-shadow: 0 0 15px -3px hsl(var(--primary) / 0.2); + } + .header-control-active { + @apply header-control text-primary border-primary/30 bg-primary/10; + } + + .stat-badge { + @apply flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-secondary/50 border border-secondary-border text-xs font-medium text-muted-foreground; + } + .stat-badge-icon { + @apply flex items-center justify-center w-6 h-6 rounded-lg bg-primary/15 text-primary; + } + .stat-badge-icon-accent { + @apply flex items-center justify-center w-6 h-6 rounded-lg bg-accent/15 text-accent; + } + .stat-badge-icon-green { + @apply flex items-center justify-center w-6 h-6 rounded-lg bg-green-500/15 text-green-500; + } + .stat-badge-icon-blue { + @apply flex items-center justify-center w-6 h-6 rounded-lg bg-blue-500/15 text-blue-500; + } + + .course-cover { + @apply relative w-full aspect-video rounded-lg overflow-hidden; + background-size: cover; + background-position: center; + } + .course-cover-hashicorp { + background: + radial-gradient(ellipse at 30% 20%, hsl(180 80% 35% / 0.3), transparent 50%), + linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%); + } + .course-cover-linux { + background: + radial-gradient(ellipse at 70% 80%, hsl(120 80% 35% / 0.3), transparent 50%), + linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%); + } + .course-cover-code { + background: + radial-gradient(ellipse at 50% 50%, hsl(260 80% 45% / 0.25), transparent 60%), + linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%); + } + .course-cover-automation { + background: + radial-gradient(ellipse at 20% 20%, hsl(45 100% 50% / 0.3), transparent 50%), + radial-gradient(ellipse at 80% 80%, hsl(160 100% 42% / 0.3), transparent 50%), + linear-gradient(135deg, hsl(var(--background) / 1) 0%, hsl(var(--background) / 1) 100%); + } + + .video-thumb { + @apply relative w-full aspect-video rounded-lg overflow-hidden; + background-size: cover; + background-position: center; + } + .video-thumb-code { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--background) / 1) 100%); } + .video-thumb-flow { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--primary) / 0.15) 100%); } + .video-thumb-presenter { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--accent) / 0.15) 100%); } + .video-thumb-linux { background: linear-gradient(135deg, hsl(var(--muted) / 1) 0%, hsl(var(--primary) / 0.1) 100%); } + + .thumb-overlay { + @apply absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 transition-opacity duration-300; + } + .group:hover .thumb-overlay { + @apply opacity-100; + } + .play-button-glow { + @apply relative w-16 h-16 rounded-full bg-primary/90 flex items-center justify-center text-primary-foreground transition-all duration-300 hover:scale-110 hover:bg-primary; + box-shadow: 0 0 30px -5px hsl(var(--primary-glow)), 0 8px 24px -8px hsl(var(--background) / 0.5); + } + .play-button-glow::before { + content: ''; + @apply absolute inset-0 rounded-full bg-primary/30 animate-pulse; + } + + .gradient-progress { + @apply relative h-2 rounded-full overflow-hidden bg-secondary; + } + .gradient-progress::-webkit-progress-bar { + @apply bg-secondary rounded-full; + } + .gradient-progress::-webkit-progress-value { + @apply rounded-full; + background: linear-gradient(90deg, hsl(160 100% 42%), hsl(150 100% 45%), hsl(140 100% 48%)); + background-size: 200% 100%; + animation: progress-shimmer 2s ease-in-out infinite; + box-shadow: 0 0 10px -2px hsl(160 100% 42% / 0.6); + } + .gradient-progress-accent::-webkit-progress-value { + background: linear-gradient(90deg, hsl(45 100% 58%), hsl(35 100% 62%), hsl(25 100% 66%)); + background-size: 200% 100%; + animation: progress-shimmer 2s ease-in-out infinite; + box-shadow: 0 0 10px -2px hsl(45 100% 58% / 0.6); + } + progress.gradient-progress { + @apply appearance-none; + background: linear-gradient(hsl(var(--secondary)), hsl(var(--secondary))); + } + progress.gradient-progress::-webkit-progress-bar { + @apply bg-secondary rounded-full; + } + progress.gradient-progress::-webkit-progress-value { + @apply rounded-full; + background: linear-gradient(90deg, hsl(160 100% 42%), hsl(150 100% 45%), hsl(140 100% 48%)); + background-size: 200% 100%; + animation: progress-shimmer 2s ease-in-out infinite; + box-shadow: 0 0 10px -2px hsl(160 100% 42% / 0.6); + } + progress.gradient-progress-accent::-webkit-progress-value { + background: linear-gradient(90deg, hsl(45 100% 58%), hsl(35 100% 62%), hsl(25 100% 66%)); + background-size: 200% 100%; + animation: progress-shimmer 2s ease-in-out infinite; + box-shadow: 0 0 10px -2px hsl(45 100% 58% / 0.6); + } + @keyframes progress-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + + .custom-scrollbar::-webkit-scrollbar { width: 10px; height: 10px; } + .custom-scrollbar::-webkit-scrollbar-track { background: hsl(var(--secondary) / 0.65); border-radius: 9999px; } + .custom-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--primary) / 0.65); + border-radius: 9999px; + border: 2px solid hsl(var(--secondary) / 0.65); + } + .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: hsl(var(--primary) / 0.9); } + .custom-scrollbar { scrollbar-width: thin; scrollbar-color: hsl(var(--primary) / 0.7) hsl(var(--secondary) / 0.65); } + + .text-gradient-primary { + background: linear-gradient(135deg, hsl(160 100% 42%), hsl(150 100% 50%)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + } + .text-gradient-accent { + background: linear-gradient(135deg, hsl(45 100% 58%), hsl(35 100% 62%)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + } + + .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } + + .video-player-container { @apply relative bg-black rounded-lg overflow-hidden; } + .video-player-container video { @apply w-full h-full object-contain; } +} + +@layer utilities { + .text-balance { text-wrap: balance; } + .animate-fade-in { animation: fadeIn 0.6s ease-out forwards; opacity: 0; } + .animate-slide-up { animation: slideUp 0.6s ease-out forwards; opacity: 0; transform: translateY(20px); } + .animate-slide-in { animation: slideIn 0.3s ease-out forwards; opacity: 0; transform: translateX(100%); } + .animate-float { animation: float 3s ease-in-out infinite; } + @keyframes fadeIn { to { opacity: 1; } } + @keyframes slideUp { to { opacity: 1; transform: translateY(0); } } + @keyframes slideIn { to { opacity: 1; transform: translateX(0); } } + @keyframes float { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(-4px); } } + @keyframes pulse-glow { 0%, 100% { box-shadow: 0 0 15px -3px hsl(var(--primary-glow)); } 50% { box-shadow: 0 0 25px -2px hsl(var(--primary-glow)), 0 0 40px -5px hsl(var(--primary-glow)); } } + .pulse-glow { animation: pulse-glow 2s ease-in-out infinite; } +} \ No newline at end of file diff --git a/app/health/page.tsx b/app/health/page.tsx new file mode 100644 index 0000000..c562bfd --- /dev/null +++ b/app/health/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function HealthPage() { + redirect('/progress') +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100755 index 0000000..07f45cc --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,85 @@ +import type { Metadata, Viewport } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' +import { Providers } from './providers' +import Script from 'next/script' + +const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }) + +export const metadata: Metadata = { + title: { + default: 'OfflineAcademy — Your Personal Offline Learning Center', + template: '%s | OfflineAcademy', + }, + description: 'A self-hosted, privacy-first learning platform for offline courses. Download, organize, and watch your educational content without internet.', + keywords: ['offline learning', 'course platform', 'self-hosted', 'video courses', 'education', 'LMS'], + authors: [{ name: 'OfflineAcademy' }], + creator: 'OfflineAcademy', + publisher: 'OfflineAcademy', + robots: 'noindex, nofollow', + openGraph: { + type: 'website', + locale: 'en_US', + url: 'http://localhost:6767', + siteName: 'OfflineAcademy', + title: 'OfflineAcademy — Your Personal Offline Learning Center', + description: 'A self-hosted, privacy-first learning platform for offline courses.', + images: [ + { url: '/og-image.svg', width: 1200, height: 630, alt: 'OfflineAcademy Dashboard' }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'OfflineAcademy', + description: 'Your Personal Offline Learning Center', + }, + icons: { + icon: [ + { url: '/favicon.svg', type: 'image/svg+xml', sizes: 'any' }, + { url: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { url: '/icon-512.png', sizes: '512x512', type: 'image/png' }, + ], + shortcut: '/favicon.svg', + apple: '/apple-touch-icon.png', + }, +} + +export const viewport: Viewport = { + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0e14' }, + ], + colorScheme: 'dark', + width: 'device-width', + initialScale: 1, + maximumScale: 5, +} + +export default function RootLayout({ + children, +}: { children: React.ReactNode }) { + return ( + + + + + + + + + + + + + {children} + + + + ) +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..953ea43 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,432 @@ +'use client' + +import * as React from 'react' +import { CourseCard } from '@/components/CourseCard' +import { Header } from '@/components/Header' +import { BookOpen, Plus, Search, Filter, Settings, Play, Clock, CheckCircle, BarChart3, Zap, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import Link from 'next/link' +import { formatTime } from '@/lib/utils' +import { useCourses } from '@/lib/hooks/useCourses' +import { getCourseDisplayName } from '@/lib/course-display' + +export default function Dashboard() { + const { + courses, + tags, + loading, + error, + pagination, + search, + setSearch, + filter, + setFilter, + sortBy, + sortOrder, + setSortBy, + setSortOrder, + nextPage, + prevPage, + changeLimit, + goToPage, + refetch, + toggleSortOrder, + } = useCourses({ initialLimit: 12 }) + + const [continueWatching, setContinueWatching] = React.useState([]) + const [completedCourses, setCompletedCourses] = React.useState([]) + const [cwLoading, setCwLoading] = React.useState(true) + const [ccLoading, setCcLoading] = React.useState(true) + const categoryTags = React.useMemo( + () => tags.filter((tag: any) => tag.name.startsWith('category:')), + [tags] + ) + const plainTags = React.useMemo( + () => tags.filter((tag: any) => !tag.name.startsWith('category:')), + [tags] + ) + + React.useEffect(() => { + const fetchContinueWatching = async () => { + try { + const res = await fetch('/api/progress?type=continue') + const data = await res.json() + setContinueWatching(data.items || data) + } catch (e) { + console.error('Failed to fetch continue watching:', e) + } finally { + setCwLoading(false) + } + } + + const fetchCompletedCourses = async () => { + try { + const res = await fetch('/api/progress?type=completed') + const data = await res.json() + setCompletedCourses(data.items || data) + } catch (e) { + console.error('Failed to fetch completed courses:', e) + } finally { + setCcLoading(false) + } + } + + fetchContinueWatching() + fetchCompletedCourses() + }, []) + + const getVideoThumb = (lessonType: string, courseName: string) => { + const name = courseName.toLowerCase() + if (name.includes('linux') || name.includes('terraform')) return 'video-thumb-linux' + if (name.includes('code') || name.includes('program')) return 'video-thumb-code' + if (name.includes('automat') || name.includes('workflow')) return 'video-thumb-flow' + return 'video-thumb-presenter' + } + + if (loading) { + return ( +
+
+
+
+
+
+

Loading courses...

+
+
+
+
+ ) + } + + if (courses.length === 0) { + return ( +
+
+
+
+
+ +
+

No courses found

+

+ Add course folders to your My_Courses/ directory, following the scan page folder structure, then scan to populate your library. +

+ + + +
+
+
+ ) + } + + return ( +
+
+ + {/* Stats Bar */} +
+
+
+ {/* Search/Filter bar */} +
+
+ + setSearch(e.target.value)} + className="pl-10 pr-4 py-2 bg-background border border-white/10 rounded-lg text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 w-full" + /> +
+ +
+ + {/* Sort dropdown */} +
+ + +
+ + {/* Stats */} +
+
+ + {pagination.total} courses +
+
+ + {courses.reduce((sum, c) => sum + c._count.lessons, 0)} lessons +
+
+ + {courses.filter(c => c.progress.percentage === 100 && c._count.lessons > 0).length} completed +
+
+ + {courses.filter(c => c.progress.percentage > 0 && c.progress.percentage < 100).length} in progress +
+
+
+
+
+ +
+ {error && ( +
+ {error} + +
+ )} + + {/* Continue Watching */} + {!cwLoading && continueWatching.length > 0 && search.trim() === '' && ( +
+
+

+
+ +
+ Continue Watching +

+
+ +
+ {continueWatching.slice(0, 1).map((item, index) => { + const courseTitle = getCourseDisplayName(item.course) + const thumbClass = getVideoThumb(item.lesson.type, courseTitle) + return ( + +
+ +
+ {item.progress.position && item.lesson.duration && ( +
+
+
+ )} +
+
+

{courseTitle}

+

{item.lesson.title}

+
+ + + {item.lesson.duration + ? formatTime(item.progress.position || 0) + ' / ' + formatTime(item.lesson.duration) + : formatTime(item.progress.position || 0)} + + {item.lesson.duration && ( + + )} +
+
+ + + ) + })} +
+
+
+ )} + + {/* Completed Courses */} + {!ccLoading && completedCourses.length > 0 && ( +
+
+

+
+ +
+ Completed Courses +

+
+ +
+ {completedCourses.map((course) => { + const courseTitle = getCourseDisplayName(course) + return ( + + + +
+ + {courseTitle} + 100% +
+
+
+ + ) + })} +
+
+
+ )} + + {/* Your Library */} +
+

+
+ +
+ Your Library +

+
+ +
+
+ + +
+ {courses.map((course) => ( + + ))} +
+
+ + {pagination.totalPages > 1 && ( +
+ +
+ {(() => { + const pages = Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => { + let pageNum + if (pagination.totalPages <= 5) { + pageNum = i + 1 + } else if (pagination.page <= 3) { + pageNum = i + 1 + } else if (pagination.page >= pagination.totalPages - 2) { + pageNum = pagination.totalPages - 4 + i + } else { + pageNum = pagination.page - 2 + i + } + return ( + + ) + }) + return pages + })()} +
+ +
+ )} +
+ Page {pagination.page} of {pagination.totalPages} — {pagination.total} courses total +
+
+
+ ) +} \ No newline at end of file diff --git a/app/progress/page.tsx b/app/progress/page.tsx new file mode 100644 index 0000000..ef7aa32 --- /dev/null +++ b/app/progress/page.tsx @@ -0,0 +1,424 @@ +import type { Metadata } from 'next' +import type { ComponentType } from 'react' +import { Badge } from '@/components/ui/badge' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { + BadgeCheck, + BookOpen, + Bookmark, + CheckCircle2, + Clock3, + Database, + Download, + FileText, + Gauge, + HelpCircle, + Keyboard, + Layers, + Laptop, + Package, + PictureInPicture2, + Play, + ShieldCheck, + Smartphone, + Tag, + Upload, + Zap, +} from 'lucide-react' + +export const metadata: Metadata = { + title: 'Progress', + description: 'Internal product status board for OfflineAcademy.', +} + +type Status = 'Live' | 'Planned' | 'Future' + +type FeatureGroup = { + title: string + status: Status + icon: ComponentType<{ className?: string }> + items: string[] + completion: number +} + +type RoadmapItem = { + label: string + done: boolean +} + +type RoadmapPhase = { + phase: string + title: string + items: RoadmapItem[] + completion: number +} + +const liveGroups: FeatureGroup[] = [ + { + title: 'Library & Scanning', + status: 'Live', + icon: Database, + completion: 100, + items: [ + 'Recursive scan of a local course root directory', + 'Course, module, and lesson discovery', + 'Natural sort for lesson ordering', + 'Folder-driven import with local file access', + ], + }, + { + title: 'Playback & Resume', + status: 'Live', + icon: Play, + completion: 100, + items: [ + 'Lesson playback inside the app', + 'Continue Watching and resume playback', + 'Auto-next / playback flow support', + 'Native HTML5 video player for speed and simplicity', + ], + }, + { + title: 'Playback Enhancements', + status: 'Live', + icon: Zap, + completion: 100, + items: [ + 'Variable playback speed (0.75x, 1x, 1.25x, 1.5x, 2.0x)', + 'Global keyboard shortcuts (play/pause, seek, mute, fullscreen, speed, PiP, Escape)', + 'Picture-in-Picture toggle', + ], + }, + { + title: 'Learning Tools', + status: 'Live', + icon: Bookmark, + completion: 100, + items: [ + 'Bookmarks with timestamp notes', + 'Jump-to-time bookmark playback', + 'Subtitle support for .srt and .vtt', + 'Inline document viewing for lessons', + ], + }, + { + title: 'Course Management', + status: 'Live', + icon: ShieldCheck, + completion: 100, + items: [ + 'Persistent rename titles', + 'Delete from library', + 'Delete from disk', + 'Archived/hidden courses stay hidden on rescans', + ], + }, + { + title: 'Progress Tracking UI', + status: 'Live', + icon: Gauge, + completion: 100, + items: [ + 'Progress bars on course cards', + 'Lesson/module manual Done toggles', + 'Local analytics dashboard (`/analytics`) with watch time, completion rates, weekly activity', + ], + }, + { + title: 'Built-in Quiz Interactivity', + status: 'Live', + icon: HelpCircle, + completion: 100, + items: [ + 'Quiz settings: Auto-Fetch toggle + QuizAPI / The Trivia API source selector', + 'QuizPlayer: intro, active questions, results, 80% pass mark, ⚙️ Edit Topic override', + 'Auto-fetch writes quiz_cache.json to module folders on module open', + 'Scanner injects Quiz lesson type (🟣) into module sidebar', + 'Quiz, Question, QuizAttempt models; score/passed tracked; green checkmark on completion', + ], + }, + { + title: 'Library Organization', + status: 'Live', + icon: Tag, + completion: 100, + items: [ + 'Custom tags & categories with color (Tag + CourseTag models)', + 'Filter by tag, favorites, in-progress, completed, not-started', + 'Sort by name, progress %, updatedAt; favorites pinned to top', + ], + }, + { + title: 'Content Types & Thumbnails', + status: 'Live', + icon: FileText, + completion: 100, + items: [ + 'PDF, TXT, MD, HTML, and JSON inline preview', + 'Local thumbnail support', + 'Safer thumbnail lookup to reduce broken image noise', + 'Watch-page rendering for mixed lesson types', + ], + }, + { + title: 'Settings & Data Model', + status: 'Live', + icon: Laptop, + completion: 100, + items: [ + 'Configured course root directory', + 'SQLite + Prisma local persistence', + 'Progress and bookmark tables', + 'No login, no accounts, no cloud storage', + ], + }, +] + +const roadmapPhases: RoadmapPhase[] = [ + { + phase: 'Phase 5', + title: 'Data Portability', + completion: 0, + items: [ + { label: 'Export local state to JSON (progress, bookmarks, notes, tags, course metadata, quiz attempts)', done: false }, + { label: 'Import and restore JSON state safely', done: false }, + ], + }, +] + +const futureIdeas = [ + { + title: 'Reset Course', + icon: Gauge, + note: 'Clear progress and start a course from zero.', + }, + { + title: 'Mobile App Wrapper', + icon: Smartphone, + note: 'Lightweight mobile wrapper or app packaging layer.', + }, + { + title: 'Import / Export Course Metadata', + icon: Package, + note: 'Move course metadata in and out without touching core progress state.', + }, +] + +const stackBadges = [ + 'Next.js 14', + 'React', + 'TypeScript', + 'Tailwind', + 'shadcn/ui', + 'Prisma', + 'SQLite', + 'Native Video', +] + +const currentCompletion = Math.round((liveGroups.filter((group) => group.completion === 100).length / liveGroups.length) * 100) + +export default function ProgressPage() { + return ( +
+
+
+
+
+
+ + + Internal route + + + + Local-only + + + + Fast stack + +
+ +

+ OfflineAcademy Progress +

+

+ A private status board for the app’s live features, roadmap, and future ideas. + This page is intentionally not linked from the dashboard — it’s an internal route + you can visit directly at /progress. +

+ +
+ {stackBadges.map((item) => ( + + {item} + + ))} +
+
+ + + + + + Current Build Progress + + + +
+
+ Live core complete + {currentCompletion}% +
+ +
+
+
+
Live feature groups
+
{liveGroups.length}
+
+
+
Roadmap phases
+
{roadmapPhases.length}
+
+
+
+
+
+
+ +
+ + + + + What’s Live Today + + + + {liveGroups.map((group) => { + const Icon = group.icon + return ( +
+
+
+ + + +
+

{group.title}

+

{group.status}

+
+
+ {group.completion}% +
+ +
    + {group.items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ) + })} +
+
+ + + + + + Roadmap Phases + + + + {roadmapPhases.map((phase) => ( +
+
+
+
{phase.phase}
+

{phase.title}

+
+ {phase.completion}% +
+ +
    + {phase.items.map((item) => ( +
  • + {item.done ? ( + + ) : ( + + )} + {item.label} +
  • + ))} +
+
+ ))} +
+
+
+ +
+ + + + + Future Ideas + + + + {futureIdeas.map((idea) => { + const Icon = idea.icon + return ( +
+
+ + + +

{idea.title}

+
+

{idea.note}

+
+ ) + })} +
+
+ + + + + + Product Constraints + + + +

No authentication, no accounts, no user profiles.

+

Everything stays local: folder scanning, SQLite, and file serving.

+

The app is built to stay fast as more courses are added.

+

This route is a direct internal progress page, not a dashboard menu item.

+
+ Playback speed & shortcuts: Live + PiP: Live + Quizzes: Live + Export planned + Import planned +
+
+
+
+
+
+ ) +} + diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100755 index 0000000..e2363c0 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,27 @@ +'use client' + +import * as React from 'react' +import { ThemeProvider } from '@/components/ThemeToggle' +import { ToastProvider } from '@/hooks/use-toast' +import { SplashScreen } from '@/components/SplashScreen' + +export function Providers({ children }: { children: React.ReactNode }) { + const [showSplash, setShowSplash] = React.useState(true) + + // Safety fallback: force hide after 5 seconds max + React.useEffect(() => { + const timer = setTimeout(() => setShowSplash(false), 5000) + return () => clearTimeout(timer) + }, []) + + return ( + + + {showSplash && ( + setShowSplash(false)} minDuration={1500} /> + )} + {!showSplash && children} + + + ) +} \ No newline at end of file diff --git a/app/scan/page.tsx b/app/scan/page.tsx new file mode 100755 index 0000000..2957833 --- /dev/null +++ b/app/scan/page.tsx @@ -0,0 +1,215 @@ +'use client' + +import * as React from 'react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { Loader2, CheckCircle, AlertCircle, RefreshCw, Database, FolderOpen, Play } from 'lucide-react' +import Link from 'next/link' +import { Header } from '@/components/Header' + +export default function ScanPage() { + const [scanning, setScanning] = useState(false) + const [coursesRoot, setCoursesRoot] = useState('./My_Courses') + const [result, setResult] = useState<{ + coursesCreated: number + coursesUpdated: number + modulesCreated: number + modulesUpdated: number + lessonsCreated: number + lessonsUpdated: number + errors: string[] + duration: number + } | null>(null) + const [logs, setLogs] = useState([]) + + const addLog = (message: string) => { + setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]) + } + + React.useEffect(() => { + fetch('/api/settings') + .then(res => res.json()) + .then(data => { + if (data.coursesRoot) setCoursesRoot(data.coursesRoot) + }) + .catch(() => {}) + }, []) + + const handleScan = async () => { + setScanning(true) + setResult(null) + setLogs([]) + addLog('Starting scan...') + + try { + const response = await fetch('/api/scan', { method: 'POST' }) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Scan failed') + } + + setResult(data) + addLog(`Scan completed in ${data.duration}ms`) + addLog(`Courses: ${data.coursesCreated} created, ${data.coursesUpdated} updated`) + addLog(`Modules: ${data.modulesCreated} created, ${data.modulesUpdated} updated`) + addLog(`Lessons: ${data.lessonsCreated} created, ${data.lessonsUpdated} updated`) + + if (data.errors.length > 0) { + data.errors.forEach((err: string) => addLog(`ERROR: ${err}`)) + } + } catch (error) { + addLog(`ERROR: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setScanning(false) + } + } + + return ( +
+
+
+
+
+ +
+

Scan Courses

+

+ Scans your My_Courses/ directory for new content. + Courses are detected from the folder tree below. +

+
+ + + + + + Start Scan + + + Recursively scans for courses, modules, and lessons. Updates existing records, adds new ones, and keeps renamed display titles. + + + + +

+ Quick scan skips video metadata for speed. For full scan with metadata, use the Scan page. +

+
+
+ + {result && ( + + + + {result.errors.length > 0 ? ( + + ) : ( + + )} + Scan Results + + + +
+
+

{result.coursesCreated + result.coursesUpdated}

+

Courses

+
+
+

{result.modulesCreated + result.modulesUpdated}

+

Modules

+
+
+

{result.lessonsCreated + result.lessonsUpdated}

+

Lessons

+
+
+
+ Completed in {result.duration}ms +
+
+
+ )} + + {logs.length > 0 && ( + + + + + Scan Logs + + + +
+ {logs.map((log, i) => ( +
{log}
+ ))} +
+
+
+ )} + + + + + + Expected Folder Structure + + + +
+{coursesRoot || './My_Courses'}/
+├── Course Name 1/
+│   ├── 01 - Introduction/
+│   │   ├── 01 - Introduction.mp4
+│   │   ├── 01 - Introduction.srt
+│   │   ├── 02 - Overview.pdf
+│   │   └── 03 - Notes.txt
+│   ├── 02 - Advanced/
+│   │   ├── 01 - Deep Dive.mp4
+│   │   └── 01 - Deep Dive.vtt
+│   └── cover.jpg   ← optional thumbnail
+├── Course Name 2/
+│   ├── Module A/
+│   │   ├── lesson1.mp4
+│   │   ├── lesson1.srt
+│   │   └── notes.md
+│   └── Module B/
+│       └── lesson1.json
+└── Course Name 3/
+    └── Module 1/
+        ├── video1.mp4
+        ├── diagram.pdf
+        └── README.txt
+            
+
+

How the scanner reads it: course folder → module folder → lesson files.

+

Matching rule: subtitle files should share the same base name as the video, like lesson.mp4 + lesson.srt.

+

Sorting: numeric prefixes like 01, 02, 10 keep lessons in learning order.

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100755 index 0000000..c3ccf3e --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,230 @@ +'use client' + +import * as React from 'react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Loader2, Save, FolderOpen, Eye, EyeOff, Key } from 'lucide-react' +import { Header } from '@/components/Header' + +const DEFAULT_COURSES_ROOT = './My_Courses' +const DEFAULT_QUIZ_API_SOURCE = 'quizapi' + +function parseBoolean(value: string | null | undefined) { + return value === 'true' +} + +export default function SettingsPage() { + const [coursesRoot, setCoursesRoot] = useState(DEFAULT_COURSES_ROOT) + const [autoFetchQuizzes, setAutoFetchQuizzes] = useState(false) + const [quizApiSource, setQuizApiSource] = useState(DEFAULT_QUIZ_API_SOURCE) + const [quizApiKey, setQuizApiKey] = useState('') + const [showApiKey, setShowApiKey] = useState(false) + const [saving, setSaving] = useState(false) + const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle') + const [statusMessage, setStatusMessage] = useState('') + + const fetchSettings = async () => { + try { + const res = await fetch('/api/settings') + if (res.ok) { + const data = await res.json() + setCoursesRoot(data.coursesRoot || DEFAULT_COURSES_ROOT) + setAutoFetchQuizzes(parseBoolean(data.autoFetchQuizzes)) + setQuizApiSource(data.quizApiSource || DEFAULT_QUIZ_API_SOURCE) + setQuizApiKey(data.quizApiKey || '') + return + } + } catch (error) { + console.error('Failed to fetch settings:', error) + } + + setCoursesRoot(DEFAULT_COURSES_ROOT) + setAutoFetchQuizzes(false) + setQuizApiSource(DEFAULT_QUIZ_API_SOURCE) + setQuizApiKey('') + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + setStatus('idle') + + try { + const saveSettings = { + coursesRoot, + autoFetchQuizzes: autoFetchQuizzes ? 'true' : 'false', + quizApiSource, + quizApiKey, + } + + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(saveSettings), + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data?.error || 'Failed to save') + } + + setStatus('success') + setStatusMessage('Settings saved. Next scan will use the new directory.') + } catch (error) { + setStatus('error') + setStatusMessage(error instanceof Error ? error.message : 'Failed to save settings') + } finally { + setSaving(false) + } + } + + React.useEffect(() => { + fetchSettings() + }, []) + + return ( +
+
+
+
+
+ +
+

Settings

+

Configure your OfflineAcademy preferences

+
+ + + + + + Courses Directory + + + The root folder where OfflineAcademy scans for courses. Use an absolute path for best results. + Add course folders using the structure shown on the Scan page. + + + +
+
+ +
+ setCoursesRoot(e.target.value)} + className="flex-1" + disabled={saving} + /> + +
+

Current default: ./My_Courses

+
+ +
+
+ +

Quiz Settings

+
+ +
+ + +

Source used when auto-fetching quiz content.

+
+ +
+
+
+ +

Fetch quiz data when a module is opened.

+
+ setAutoFetchQuizzes(event.target.checked)} + disabled={saving} + /> +
+
+ + {quizApiSource === 'quizapi' && ( +
+ +
+ setQuizApiKey(e.target.value)} + className="pr-10" + disabled={saving} + autoComplete="off" + /> + +
+

Get your key at quizapi.io. Stored securely in local database.

+
+ )} + +
+

Quiz settings

+

Auto-fetch is {autoFetchQuizzes ? 'enabled' : 'disabled'} with source {quizApiSource}.

+ {quizApiSource === 'quizapi' && quizApiKey && ( +

✓ QuizAPI key configured ({quizApiKey.length} characters)

+ )} + {quizApiSource === 'quizapi' && !quizApiKey && ( +

⚠ QuizAPI selected but no API key configured. Quizzes will fall back to The Trivia API.

+ )} +
+
+ + + + {status === 'success' && ( +

Settings saved successfully.

+ )} + {status === 'error' && ( +

{statusMessage}

+ )} +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/watch/[lessonId]/WatchPageClient.tsx b/app/watch/[lessonId]/WatchPageClient.tsx new file mode 100755 index 0000000..f87290a --- /dev/null +++ b/app/watch/[lessonId]/WatchPageClient.tsx @@ -0,0 +1,538 @@ +'use client' + +import * as React from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { Header } from '@/components/Header' +import { VideoPlayer } from '@/components/VideoPlayer' +import { ModuleAccordion } from '@/components/ModuleAccordion' +import { LessonBookmarks } from '@/components/LessonBookmarks' +import { QuizPlayer } from '@/components/QuizPlayer' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { + Play, Clock, CheckCircle, BookOpen, ArrowLeft, + ChevronLeft, ChevronRight, Volume2, VolumeX, + Maximize, Minimize, Settings, Loader2, SkipBack, SkipForward, + Film, Music, FileText, Image as ImageIcon, HelpCircle, +} from 'lucide-react' +import Link from 'next/link' +import { formatDuration, formatTime, cn } from '@/lib/utils' +import { getCourseDisplayName } from '@/lib/course-display' + +interface LessonType { + id: string + title: string + slug: string + order: number + moduleId: string + module: { name: string; courseId?: string; id: string } + type: string + duration: number | null + filePath: string + thumbnail: string | null + subtitlePath: string | null + progress?: { completed: boolean; position: number; lastWatched: string } | null + quiz?: { + version: 1 + source: 'quizapi' | 'the-trivia-api' + topic: string + title: string + description: string + fetchedAt: string + updatedAt: string + questions: Array<{ + id: string + prompt: string + options: string[] + answerIndex: number + explanation?: string + }> + } | null +} + +interface CourseType { + id: string + slug: string + name: string + modules: Array<{ + id: string + name: string + slug: string + order: number + lessons: Array<{ + id: string + title: string + slug: string + order: number + type: string + duration: number | null + moduleId: string + progress?: { completed: boolean; position: number; lastWatched: Date | string | null } | null + }> + }> + stats: { + totalLessons: number + completedLessons: number + percentage: number + } +} + +interface WatchPageClientProps { + initialData: { + lesson: LessonType + course: CourseType + prevLesson: { id: string; courseSlug: string } | null + nextLesson: { id: string; courseSlug: string } | null + currentIndex: number + totalLessons: number + } +} + +const lessonTypeIcons: Record = { + VIDEO: , + AUDIO: , + PDF: , + MARKDOWN: , + HTML: , + IMAGE: , + QUIZ: , + OTHER: , +} + +function saveProgressFn(lessonId: string, courseId: string, moduleId: string, position: number) { + return fetch('/api/progress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lessonId, courseId, moduleId, position, completed: false }) + }) +} + +function markCompleteFn(lessonId: string, courseId: string, moduleId: string, position: number) { + return fetch('/api/progress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lessonId, courseId, moduleId, position, completed: true }) + }) +} + +function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, seekRequest }: { + lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null } + lessonProgress: { completed: boolean; position: number; lastWatched: string } + onSave: (position: number) => void + onEnd: () => void + onTimeUpdate: (time: number) => void + seekRequest: { time: number; nonce: number } | null +}) { + return ( + + ) +} + +function NonVideoContent({ lesson }: { lesson: { type: string; filePath: string; title: string; duration: number | null } }) { + return ( +
+
+ {lesson.type === 'AUDIO' && ( +